feat: separate author cover size/shape settings with circle layout

This commit is contained in:
neon
2026-03-16 06:55:38 -06:00
parent 52f1727fb1
commit d0329aa391
8 changed files with 226 additions and 22 deletions
+57 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue'
import { CheckSquare, LayoutGrid, List, MoreHorizontal, SlidersHorizontal, Square } from 'lucide-vue-next'
import { CheckSquare, Circle, LayoutGrid, List, MoreHorizontal, SlidersHorizontal, Square } from 'lucide-vue-next'
import * as LucideIcons from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
@@ -23,6 +23,7 @@ defineProps<{
gridGap: number
viewMode: 'grid' | 'list'
selectionMode?: boolean
coverShape?: 'square' | 'circle'
}>()
const emit = defineEmits<{
@@ -30,6 +31,7 @@ const emit = defineEmits<{
'update:gridGap': [value: number]
'update:viewMode': [value: 'grid' | 'list']
'toggle-selection': []
'update:coverShape': [value: 'square' | 'circle']
}>()
const mobileDisplayOpen = ref(false)
@@ -140,6 +142,33 @@ function getIconComponent(name: string) {
class="w-full accent-primary cursor-pointer"
/>
</div>
<div v-if="coverShape !== undefined" class="space-y-1.5">
<span class="text-xs text-muted-foreground">Cover shape</span>
<div class="flex items-center gap-1 mt-1.5">
<button
class="flex flex-1 items-center justify-center gap-1.5 rounded-md border py-1.5 text-xs font-medium transition-colors"
:class="
coverShape === 'circle'
? 'border-primary text-primary bg-primary/8'
: 'border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground/40'
"
@click="emit('update:coverShape', 'circle')"
>
<Circle :size="12" /> Circle
</button>
<button
class="flex flex-1 items-center justify-center gap-1.5 rounded-md border py-1.5 text-xs font-medium transition-colors"
:class="
coverShape === 'square'
? 'border-primary text-primary bg-primary/8'
: 'border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground/40'
"
@click="emit('update:coverShape', 'square')"
>
<Square :size="12" /> Square
</button>
</div>
</div>
</div>
</PopoverContent>
</Popover>
@@ -209,6 +238,33 @@ function getIconComponent(name: string) {
class="w-full accent-primary cursor-pointer"
/>
</div>
<div v-if="coverShape !== undefined" class="space-y-1.5">
<span class="text-xs text-muted-foreground">Cover shape</span>
<div class="flex items-center gap-1 mt-1.5">
<button
class="flex flex-1 items-center justify-center gap-1.5 rounded-md border py-1.5 text-xs font-medium transition-colors"
:class="
coverShape === 'circle'
? 'border-primary text-primary bg-primary/8'
: 'border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground/40'
"
@click="emit('update:coverShape', 'circle')"
>
<Circle :size="12" /> Circle
</button>
<button
class="flex flex-1 items-center justify-center gap-1.5 rounded-md border py-1.5 text-xs font-medium transition-colors"
:class="
coverShape === 'square'
? 'border-primary text-primary bg-primary/8'
: 'border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground/40'
"
@click="emit('update:coverShape', 'square')"
>
<Square :size="12" /> Square
</button>
</div>
</div>
</div>
</SheetContent>
</Sheet>
+6 -1
View File
@@ -2,19 +2,24 @@ import { ref, watch } from 'vue'
import { storage } from '@/services/storage'
export type CardOverlayKey = 'series' | 'progress-bar' | 'progress-pill' | 'format' | 'rating' | 'new'
export type AuthorCoverShape = 'square' | 'circle'
const coverSize = ref(storage.get('coverSize', 140))
const gridGap = ref(storage.get('gridGap', 20))
const viewMode = ref<'grid' | 'list'>(storage.get('viewMode', 'grid'))
const cardOverlays = ref<CardOverlayKey[]>(storage.get('cardOverlays', ['progress-bar', 'format', 'rating']))
const lensFilterExpanded = ref(storage.get('lensFilterExpanded', true))
const authorCoverSize = ref(storage.get('authorCoverSize', 120))
const authorCoverShape = ref<AuthorCoverShape>(storage.get('authorCoverShape', 'circle'))
watch(coverSize, (v) => storage.set('coverSize', v))
watch(gridGap, (v) => storage.set('gridGap', v))
watch(viewMode, (v) => storage.set('viewMode', v))
watch(cardOverlays, (v) => storage.set('cardOverlays', v), { deep: true })
watch(lensFilterExpanded, (v) => storage.set('lensFilterExpanded', v))
watch(authorCoverSize, (v) => storage.set('authorCoverSize', v))
watch(authorCoverShape, (v) => storage.set('authorCoverShape', v))
export function useDisplaySettings() {
return { coverSize, gridGap, viewMode, cardOverlays, lensFilterExpanded }
return { coverSize, gridGap, viewMode, cardOverlays, lensFilterExpanded, authorCoverSize, authorCoverShape }
}
@@ -13,6 +13,7 @@ const props = defineProps<{
canDelete?: boolean
refreshing?: boolean
deleting?: boolean
shape?: 'square' | 'circle'
}>()
const emit = defineEmits<{
@@ -55,7 +56,9 @@ function handleDelete() {
</script>
<template>
<!-- Square layout (default) -->
<div
v-if="shape !== 'circle'"
class="group flex flex-col @container"
:class="[selectionMode ? 'select-none' : '', 'cursor-pointer']"
@click="handleClick($event as MouseEvent)"
@@ -147,4 +150,96 @@ function handleDelete() {
</Transition>
</div>
</div>
<!-- Circle layout -->
<div
v-else
class="group flex flex-col items-center gap-2 py-2 @container"
:class="[selectionMode ? 'select-none' : '', 'cursor-pointer']"
@click="handleClick($event as MouseEvent)"
>
<div class="relative w-full aspect-square">
<div
class="w-full h-full rounded-full overflow-hidden shadow-md transition-[box-shadow,transform,ring] duration-150 will-change-transform"
:class="[
selectionMode ? '' : 'group-hover:shadow-xl group-hover:scale-[1.02]',
selected ? 'ring-2 ring-primary ring-offset-2 ring-offset-background' : '',
]"
:style="hasImage ? undefined : fallbackStyle"
>
<img
v-if="hasImage"
:src="imageSrc"
:alt="`${author.name} portrait`"
class="absolute inset-0 h-full w-full object-cover"
loading="lazy"
decoding="async"
fetchpriority="low"
@error="imageFailed = true"
/>
<div v-else class="absolute inset-0 flex items-center justify-center text-3xl font-semibold" :style="{ color: fallbackStyle.color }">
{{ initial }}
</div>
<div v-if="selectionMode" class="absolute inset-0 z-30 pointer-events-none rounded-full" :class="selected ? 'bg-primary/20' : ''" />
<Transition name="fade">
<div v-if="refreshing" class="absolute inset-0 z-40 flex items-center justify-center bg-black/50 rounded-full">
<Loader2 class="size-[32cqi] animate-spin text-white drop-shadow-lg" />
</div>
</Transition>
</div>
<div v-if="selectionMode" class="absolute top-1 left-1 z-30">
<div
class="h-5 w-5 rounded flex items-center justify-center transition-colors"
:class="selected ? 'bg-primary' : 'bg-black/40 border border-white/50'"
>
<Check v-if="selected" class="text-primary-foreground" :size="12" />
</div>
</div>
<span
v-if="!selectionMode"
class="absolute bottom-0.5 left-0.5 z-20 h-6 w-6 rounded-full border border-border bg-card shadow-sm flex items-center justify-center text-[10px] font-semibold text-foreground"
>
{{ author.bookCount.toLocaleString() }}
</span>
<DropdownMenu v-if="!selectionMode">
<DropdownMenuTrigger as-child>
<button
class="absolute bottom-0.5 right-0.5 z-20 h-6 w-6 rounded-full border border-border bg-card shadow-sm flex items-center justify-center text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
@click.stop
>
<MoreHorizontal :size="12" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="emit('open', author.id)">
<ExternalLink class="mr-2 h-4 w-4" />
View Author Details
</DropdownMenuItem>
<DropdownMenuItem :disabled="!canRefresh || refreshing" @click="handleRefresh">
<Loader2 v-if="refreshing" class="mr-2 h-4 w-4 animate-spin" />
<RefreshCw v-else class="mr-2 h-4 w-4" />
Refresh Metadata
</DropdownMenuItem>
<DropdownMenuItem
:disabled="!canDelete || deleting"
:class="canDelete ? 'text-destructive focus:text-destructive' : ''"
@click="handleDelete"
>
<Loader2 v-if="deleting" class="mr-2 h-4 w-4 animate-spin" />
<Trash2 v-else class="mr-2 h-4 w-4" />
Delete Author
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div class="flex flex-col items-center gap-0.5 w-full min-w-0 px-1 text-center">
<h3 class="w-full truncate text-xs font-semibold text-foreground leading-tight">{{ author.name }}</h3>
</div>
</div>
</template>
@@ -28,7 +28,7 @@ import type { AuthorListSort, SortDirection } from '../types/author'
const router = useRouter()
const route = useRoute()
const { coverSize, gridGap, viewMode } = useDisplaySettings()
const { gridGap, viewMode, authorCoverSize, authorCoverShape } = useDisplaySettings()
const { libraries, fetchLibraries } = useLibraries()
const { hasPermission, isSuperuser } = usePermissions()
const { items, total, loading, error, hasMore, q, sort, order, libraryId, load } = useAuthorsList()
@@ -425,9 +425,10 @@ watch(
title="Authors"
icon="Users"
:total="total"
v-model:coverSize="coverSize"
v-model:coverSize="authorCoverSize"
v-model:gridGap="gridGap"
v-model:viewMode="viewMode"
v-model:coverShape="authorCoverShape"
:selection-mode="selectionMode"
@toggle-selection="toggleSelectionMode"
>
@@ -523,13 +524,13 @@ watch(
<div
v-if="!initialLoadComplete && loading"
class="grid"
:style="{ gridTemplateColumns: `repeat(auto-fill, minmax(${coverSize}px, 1fr))`, gap: `${gridGap}px` }"
:style="{ gridTemplateColumns: `repeat(auto-fill, minmax(${authorCoverSize}px, 1fr))`, gap: `${gridGap}px` }"
>
<div
v-for="index in INITIAL_SKELETON_COUNT"
:key="`author-skeleton-${index}`"
class="w-full animate-pulse rounded-sm bg-muted/50"
style="aspect-ratio: 2/3"
class="w-full animate-pulse bg-muted/50"
:class="authorCoverShape === 'circle' ? 'rounded-full aspect-square' : 'rounded-sm aspect-[2/3]'"
/>
</div>
@@ -541,12 +542,13 @@ watch(
<div
v-show="viewMode === 'grid' && items.length > 0"
class="grid"
:style="{ gridTemplateColumns: `repeat(auto-fill, minmax(${coverSize}px, 1fr))`, gap: `${gridGap}px` }"
:style="{ gridTemplateColumns: `repeat(auto-fill, minmax(${authorCoverSize}px, 1fr))`, gap: `${gridGap}px` }"
>
<AuthorCard
v-for="author in items"
:key="author.id"
:author="author"
:shape="authorCoverShape"
:selection-mode="selectionMode"
:selected="isSelected(author.id)"
:can-refresh="canRefreshMetadata"
@@ -1,13 +1,13 @@
<script setup lang="ts">
import { ACCENT_VIVID, ACCENT_PASTEL, BACKGROUND_OPTIONS, RADIUS_OPTIONS, useThemeStore } from '@/stores/theme'
import { Moon, Sun } from 'lucide-vue-next'
import { Circle, Moon, Square, Sun } from 'lucide-vue-next'
import { useDisplaySettings, type CardOverlayKey } from '@/composables/useDisplaySettings'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import SettingsPageHeader from './SettingsPageHeader.vue'
const themeStore = useThemeStore()
const { coverSize, gridGap, cardOverlays, lensFilterExpanded } = useDisplaySettings()
const { coverSize, gridGap, cardOverlays, lensFilterExpanded, authorCoverSize, authorCoverShape } = useDisplaySettings()
const OVERLAY_OPTIONS: { key: CardOverlayKey; label: string; hint: string }[] = [
{ key: 'series', label: 'Series name', hint: 'Series title and index badge at top-left' },
@@ -238,7 +238,55 @@ function toggleOverlay(key: CardOverlayKey) {
</div>
<!-- Card overlays -->
<p class="settings-group-label mt-6">Card Overlays</p>
<p class="settings-group-label mt-6">Author Grid</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border mb-6">
<!-- Author cover size -->
<div class="px-5 py-4 bg-card">
<div class="flex items-center justify-between mb-3">
<div>
<p class="settings-label">Cover size</p>
<p class="settings-hint">Width of author covers in the grid</p>
</div>
<span class="settings-value">{{ authorCoverSize }}px</span>
</div>
<input
:value="authorCoverSize"
@input="authorCoverSize = Number(($event.target as HTMLInputElement).value)"
type="range"
min="80"
max="280"
step="10"
class="w-full accent-primary cursor-pointer"
/>
</div>
<!-- Author cover shape -->
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div>
<p class="settings-label">Cover shape</p>
<p class="settings-hint">Shape of author covers in the grid</p>
</div>
<div class="flex items-center gap-1 p-1 rounded-lg border border-border bg-muted/50">
<button
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
:class="authorCoverShape === 'circle' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="authorCoverShape = 'circle'"
>
<Circle :size="12" /> Circle
</button>
<button
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
:class="authorCoverShape === 'square' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="authorCoverShape = 'square'"
>
<Square :size="12" /> Square
</button>
</div>
</div>
</div>
<!-- Card overlays -->
<p class="settings-group-label">Card Overlays</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div v-for="opt in OVERLAY_OPTIONS" :key="opt.key" class="flex items-center justify-between px-5 py-3.5 bg-card">
<div>
+7 -11
View File
@@ -1,19 +1,15 @@
import type { Params } from 'nestjs-pino';
import type { IncomingMessage, ServerResponse } from 'http';
import { RequestMethod } from '@nestjs/common';
const isDev = process.env.NODE_ENV !== 'production';
const logLevel = process.env.LOG_LEVEL ?? (isDev ? 'debug' : 'info');
const FRAMEWORK_CONTEXTS = new Set(['InstanceLoader', 'RouterExplorer', 'RoutesResolver']);
export const loggerConfig: Params = {
exclude: [
{ method: RequestMethod.GET, path: 'books/:id/thumbnail' },
{ method: RequestMethod.GET, path: 'books/:id/cover' },
{ method: RequestMethod.GET, path: 'cbz/files/:id/pages/:page' },
],
exclude: [],
pinoHttp: {
level: isDev ? 'debug' : 'info',
level: logLevel,
hooks: {
logMethod: function (inputArgs, method) {
const first = inputArgs[0];
@@ -30,15 +26,15 @@ export const loggerConfig: Params = {
},
customProps: () => ({ context: 'HTTP' }),
customSuccessMessage: (req: IncomingMessage, res: ServerResponse, responseTime: number) => {
return `${req.method} ${req.url} ${res.statusCode} +${Math.round(responseTime)}ms`;
return `[HTTP] ${req.method} ${req.url} ${res.statusCode} +${Math.round(responseTime)}ms`;
},
customErrorMessage: (req: IncomingMessage, res: ServerResponse, err: Error) => {
return `${req.method} ${req.url} ${res.statusCode} - ${err?.message ?? 'error'}`;
return `[HTTP] ${req.method} ${req.url} ${res.statusCode} - ${err?.message ?? 'error'}`;
},
customLogLevel: (_req: IncomingMessage, res: ServerResponse, err?: Error) => {
if (err || res.statusCode >= 500) return 'error';
if (res.statusCode >= 400) return 'warn';
return 'info';
return 'debug';
},
serializers: {
req: (req: IncomingMessage & { id?: string }) => ({ id: req.id, method: req.method, url: req.url }),
@@ -53,7 +49,7 @@ export const loggerConfig: Params = {
singleLine: true,
translateTime: 'SYS:HH:MM:ss.l',
ignore: 'pid,hostname,req,res,responseTime',
messageFormat: '[{context}] {msg}',
messageFormat: '{msg}',
},
},
}
+1
View File
@@ -3,6 +3,7 @@ import { registerAs } from '@nestjs/config';
export const appConfig = registerAs('app', () => ({
port: parseInt(process.env.PORT ?? '3000', 10),
nodeEnv: process.env.NODE_ENV ?? 'development',
logLevel: process.env.LOG_LEVEL,
}));
export const dbConfig = registerAs('db', () => ({
+1
View File
@@ -26,6 +26,7 @@ const envSchema = z.object({
SMTP_FROM: z.string().default('noreply@projectx.local'),
APP_URL: z.string().url().default('http://localhost:5173'),
EMAIL_ENCRYPTION_KEY: z.string().optional(),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).optional(),
});
export function validateEnv(config: Record<string, unknown>) {