mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat: separate author cover size/shape settings with circle layout
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
Reference in New Issue
Block a user