feat(stats): add dashboard with horizontal scrollers and settings panel

This commit is contained in:
neon
2026-02-25 04:40:56 -06:00
parent bc90fc0eda
commit a6ef586dbe
20 changed files with 909 additions and 26 deletions
+28 -10
View File
@@ -1,16 +1,8 @@
<script setup lang="ts">
import * as Icons from 'lucide-vue-next'
import { Aperture, FolderOpen, Plus } from 'lucide-vue-next'
function getCollectionIcon(iconName: string | null) {
if (iconName) {
const found = (Icons as Record<string, unknown>)[iconName]
if (found) return found
}
return FolderOpen
}
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import * as Icons from 'lucide-vue-next'
import { Aperture, FolderOpen, LayoutDashboard, Plus } from 'lucide-vue-next'
import {
Sidebar,
SidebarContent,
@@ -35,6 +27,14 @@ import CreateLensDialog from '@/features/lens/components/CreateLensDialog.vue'
import CreateCollectionDialog from '@/features/collection/components/CreateCollectionDialog.vue'
import LibraryCreatorModal from '@/features/library/components/LibraryCreatorModal.vue'
function getCollectionIcon(iconName: string | null) {
if (iconName) {
const found = (Icons as Record<string, unknown>)[iconName]
if (found) return found
}
return FolderOpen
}
const router = useRouter()
const route = useRoute()
const { libraries, fetchLibraries } = useLibraries()
@@ -53,6 +53,8 @@ const activeLibraryId = computed(() => {
return route.name === 'library' && id ? Number(id) : null
})
const isDashboardActive = computed(() => route.name === 'dashboard')
const activeLensId = computed(() => {
const id = route.params.id
return route.name === 'lens' && id ? Number(id) : null
@@ -146,6 +148,22 @@ onMounted(async () => {
</SidebarHeader>
<SidebarContent>
<!-- Dashboard -->
<SidebarGroup class="pt-2 pb-0">
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton :is-active="isDashboardActive" tooltip="Dashboard" class="gap-2.5" @click="router.push({ name: 'dashboard' })">
<LayoutDashboard :size="15" :class="isDashboardActive ? 'text-sidebar-primary' : 'text-sidebar-foreground/50'" />
<span :class="isDashboardActive ? 'font-medium text-sidebar-foreground' : 'text-sidebar-foreground/70'"> Dashboard </span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarSeparator class="group-data-[collapsible=icon]:hidden" />
<!-- Libraries -->
<SidebarGroup class="pt-2">
<SidebarGroupLabel
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { BookOpen } from 'lucide-vue-next'
import type { BookCard } from '@projectx/types'
import { FORMAT_TO_GROUP } from '@projectx/types'
import { bookCoverStyle } from '@/features/book/lib/book-cover'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
const props = defineProps<{
book: BookCard
showProgress?: boolean
}>()
const router = useRouter()
const { coverUrl } = useCoverVersions()
const coverStyle = computed(() => bookCoverStyle(props.book.title ?? String(props.book.id)))
const coverSrc = computed(() => coverUrl(props.book.id))
const coverLoaded = ref(false)
const coverFailed = ref(false)
const readableFiles = computed(() => props.book.files.filter((f) => f.format && f.format in FORMAT_TO_GROUP))
const primaryFile = computed(() => readableFiles.value.find((f) => f.role === 'primary') ?? readableFiles.value[0] ?? null)
const progressPct = computed(() => (props.showProgress && props.book.readingProgress ? props.book.readingProgress : 0))
function handleClick() {
if (primaryFile.value) {
router.push({
name: 'reader',
params: { bookId: props.book.id, fileId: primaryFile.value.id },
query: { format: primaryFile.value.format ?? 'epub' },
})
} else {
router.push({ name: 'book-detail', params: { bookId: props.book.id } })
}
}
</script>
<template>
<div class="group cursor-pointer select-none" @click="handleClick">
<div
class="relative w-full overflow-hidden rounded-md shadow-md transition-all duration-200 group-hover:shadow-xl group-hover:scale-[1.04]"
style="aspect-ratio: 2/3"
:style="coverLoaded ? {} : coverStyle"
>
<img
v-if="!coverFailed"
:src="coverSrc"
class="absolute inset-0 h-full w-full object-cover transition-opacity duration-300"
:class="coverLoaded ? 'opacity-100' : 'opacity-0'"
loading="lazy"
:alt="book.title ?? ''"
@load="coverLoaded = true"
@error="coverFailed = true"
/>
<!-- Hover overlay -->
<div
class="absolute inset-0 flex items-center justify-center bg-black/55 opacity-0 backdrop-blur-[2px] transition-opacity duration-200 group-hover:opacity-100"
>
<div class="flex h-9 w-9 items-center justify-center rounded-full bg-white/20 ring-1 ring-white/30">
<BookOpen :size="16" class="text-white" />
</div>
</div>
<!-- Progress bar (continue-reading only) -->
<div v-if="progressPct > 0" class="absolute bottom-0 left-0 right-0 h-[3px] bg-black/30">
<div class="h-full bg-emerald-400" :style="{ width: progressPct + '%' }" />
</div>
</div>
</div>
</template>
@@ -0,0 +1,133 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Aperture, BookMarked, ChevronLeft, ChevronRight, RefreshCw, Shuffle, Sparkles } from 'lucide-vue-next'
import type { ScrollerType } from '@projectx/types'
import DashboardBookCard from './DashboardBookCard.vue'
import { useDashboardScroller } from '../composables/useDashboardScroller'
const props = defineProps<{
type: ScrollerType
title: string
limit?: number
lensId?: number
}>()
const { books, loading, error, refresh } = useDashboardScroller(props.type, props.limit, props.lensId)
const scrollEl = ref<HTMLElement | null>(null)
function scrollBy(delta: number) {
scrollEl.value?.scrollBy({ left: delta, behavior: 'smooth' })
}
const typeIcon = computed(() => {
if (props.type === 'continue-reading') return BookMarked
if (props.type === 'recently-added') return Sparkles
if (props.type === 'lens') return Aperture
return Shuffle
})
const typeColor = computed(() => {
if (props.type === 'continue-reading') return 'text-emerald-500'
if (props.type === 'recently-added') return 'text-violet-500'
if (props.type === 'lens') return 'text-sky-500'
return 'text-amber-500'
})
const typeBg = computed(() => {
if (props.type === 'continue-reading') return 'bg-emerald-500/10'
if (props.type === 'recently-added') return 'bg-violet-500/10'
if (props.type === 'lens') return 'bg-sky-500/10'
return 'bg-amber-500/10'
})
const SKELETONS = Array.from({ length: 8 })
</script>
<template>
<section class="group/scroller overflow-hidden rounded-2xl border border-border/40 bg-card/30 shadow-sm backdrop-blur-[1px]">
<!-- Header -->
<div class="mb-2 flex items-center justify-between px-5 pt-4">
<div class="flex items-center gap-2.5">
<div class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md" :class="typeBg">
<component :is="typeIcon" :size="14" :class="typeColor" />
</div>
<h2 class="text-[15px] font-semibold tracking-tight">{{ title }}</h2>
<span
v-if="!loading && !error && books.length > 0"
class="rounded-full px-2 py-0.5 text-[11px] font-medium tabular-nums"
:class="[typeBg, typeColor]"
>
{{ books.length }}
</span>
</div>
<div class="flex items-center gap-0.5 opacity-0 transition-opacity duration-200 group-hover/scroller:opacity-100">
<button
class="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
@click="scrollBy(-560)"
>
<ChevronLeft :size="16" />
</button>
<button
class="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
@click="scrollBy(560)"
>
<ChevronRight :size="16" />
</button>
</div>
</div>
<!-- Skeleton -->
<div v-if="loading" class="flex gap-3 overflow-hidden px-5 pb-5">
<div v-for="(_, n) in SKELETONS" :key="n" class="w-[120px] shrink-0">
<div class="w-full animate-pulse rounded-md bg-muted" style="aspect-ratio: 2/3" />
</div>
</div>
<!-- Error -->
<div v-else-if="error" class="flex items-center gap-2.5 px-5 pb-4 pt-1 text-sm text-muted-foreground">
<span>Failed to load.</span>
<button class="flex items-center gap-1.5 text-xs text-primary hover:underline" @click="refresh">
<RefreshCw :size="12" />
Retry
</button>
</div>
<!-- Empty -->
<div v-else-if="books.length === 0" class="px-5 pb-4 pt-1">
<p class="text-sm text-muted-foreground">
<template v-if="type === 'continue-reading'">No books in progress yet. Start reading one to see it here.</template>
<template v-else-if="type === 'recently-added'">No books in your library yet.</template>
<template v-else-if="type === 'lens'">No books match this lens.</template>
<template v-else>No unread books found.</template>
</p>
</div>
<!-- Books row — card's overflow:hidden gives clean edge, no gradient needed -->
<div v-else ref="scrollEl" class="flex gap-3 overflow-x-auto px-5 pb-5 pt-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div
v-for="(book, index) in books"
:key="book.id"
class="w-[120px] shrink-0"
style="animation: dashboardFadeUp 0.35s ease both"
:style="{ animationDelay: `${index * 35}ms` }"
>
<DashboardBookCard :book="book" :show-progress="type === 'continue-reading'" />
</div>
</div>
</section>
</template>
<style scoped>
@keyframes dashboardFadeUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
@@ -0,0 +1,268 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ChevronDown, ChevronUp, GripVertical, Plus, RotateCcw, Trash2 } from 'lucide-vue-next'
import type { ScrollerConfig, ScrollerType } from '@projectx/types'
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { useLenses } from '@/features/lens/composables/useLenses'
import { DEFAULT_SCROLLERS, SCROLLER_LABELS, useDashboardConfig } from '../composables/useDashboardConfig'
const props = defineProps<{ open: boolean }>()
const emit = defineEmits<{ 'update:open': [value: boolean] }>()
const { scrollers, saveScrollers, MAX_SCROLLERS } = useDashboardConfig()
const { lenses, fetchLenses } = useLenses()
const draft = ref<ScrollerConfig[]>([])
watch(
() => props.open,
(isOpen) => {
if (isOpen) {
draft.value = scrollers.value.map((s) => ({ ...s }))
fetchLenses()
}
},
)
// ── Drag to reorder ──────────────────────────────────────────
const draggedIndex = ref<number | null>(null)
const dragOverIndex = ref<number | null>(null)
function onDragStart(index: number) {
draggedIndex.value = index
}
function onDragOver(e: DragEvent, index: number) {
e.preventDefault()
dragOverIndex.value = index
}
function onDrop(index: number) {
if (draggedIndex.value === null || draggedIndex.value === index) return
const items = [...draft.value]
const [moved] = items.splice(draggedIndex.value, 1)
items.splice(index, 0, moved)
draft.value = items
draggedIndex.value = null
dragOverIndex.value = null
}
function onDragEnd() {
draggedIndex.value = null
dragOverIndex.value = null
}
// ── Add / Remove ─────────────────────────────────────────────
const ALL_TYPES: ScrollerType[] = ['continue-reading', 'recently-added', 'random', 'lens']
function addScroller() {
if (draft.value.length >= MAX_SCROLLERS) return
const maxId = Math.max(0, ...draft.value.map((s) => Number(s.id)))
draft.value.push({
id: String(maxId + 1),
type: 'recently-added',
label: SCROLLER_LABELS['recently-added'],
enabled: true,
order: draft.value.length + 1,
limit: 20,
})
}
function removeScroller(index: number) {
if (draft.value.length <= 1) return
draft.value.splice(index, 1)
}
function moveUp(index: number) {
if (index === 0) return
const items = [...draft.value]
;[items[index - 1], items[index]] = [items[index], items[index - 1]]
draft.value = items
}
function moveDown(index: number) {
if (index === draft.value.length - 1) return
const items = [...draft.value]
;[items[index], items[index + 1]] = [items[index + 1], items[index]]
draft.value = items
}
function onTypeChange(scroller: ScrollerConfig) {
if (scroller.type === 'lens') {
const firstLens = lenses.value[0]
scroller.lensId = firstLens?.id
scroller.label = firstLens?.name ?? 'Lens'
} else {
scroller.lensId = undefined
scroller.label = SCROLLER_LABELS[scroller.type]
}
}
function onLensChange(scroller: ScrollerConfig) {
const lens = lenses.value.find((l) => l.id === scroller.lensId)
if (lens) scroller.label = lens.name
}
// ── Save / Reset / Close ─────────────────────────────────────
function save() {
saveScrollers(draft.value)
emit('update:open', false)
}
function resetToDefault() {
draft.value = DEFAULT_SCROLLERS.map((s) => ({ ...s }))
}
</script>
<template>
<Sheet :open="open" @update:open="emit('update:open', $event)">
<SheetContent side="right" class="flex w-[420px] flex-col gap-0 p-0 sm:max-w-[420px]">
<!-- Header -->
<SheetHeader class="border-b border-border px-5 py-4">
<SheetTitle class="text-base font-semibold">Customize Dashboard</SheetTitle>
</SheetHeader>
<!-- Body -->
<div class="flex-1 overflow-y-auto px-5 py-4">
<p class="mb-4 text-xs text-muted-foreground">Drag to reorder. Toggle to show or hide a shelf.</p>
<div class="space-y-2">
<div
v-for="(scroller, index) in draft"
:key="scroller.id"
draggable="true"
class="rounded-lg border border-border bg-card transition-all duration-150"
:class="{
'border-primary/50 bg-primary/5 shadow-sm': dragOverIndex === index && draggedIndex !== index,
'opacity-40': draggedIndex === index,
}"
@dragstart="onDragStart(index)"
@dragover="onDragOver($event, index)"
@drop="onDrop(index)"
@dragend="onDragEnd"
>
<!-- Main row -->
<div class="flex items-center gap-3 px-3 py-2.5">
<!-- Drag handle (desktop) + up/down arrows (mobile fallback) -->
<div class="flex shrink-0 flex-col items-center">
<button
class="touch-reorder-btn flex h-5 w-5 items-center justify-center rounded text-muted-foreground/50 transition-colors hover:bg-muted hover:text-foreground disabled:opacity-20"
:disabled="index === 0"
@click="moveUp(index)"
>
<ChevronUp :size="13" />
</button>
<div class="drag-handle cursor-grab text-muted-foreground/40 hover:text-muted-foreground active:cursor-grabbing">
<GripVertical :size="16" />
</div>
<button
class="touch-reorder-btn flex h-5 w-5 items-center justify-center rounded text-muted-foreground/50 transition-colors hover:bg-muted hover:text-foreground disabled:opacity-20"
:disabled="index === draft.length - 1"
@click="moveDown(index)"
>
<ChevronDown :size="13" />
</button>
</div>
<!-- Toggle -->
<button
class="relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none"
:class="scroller.enabled ? 'bg-primary' : 'bg-muted'"
@click="scroller.enabled = !scroller.enabled"
>
<span
class="pointer-events-none block h-4 w-4 rounded-full bg-white shadow ring-0 transition-transform duration-200"
:class="scroller.enabled ? 'translate-x-4' : 'translate-x-0'"
/>
</button>
<!-- Type selector -->
<select
v-model="scroller.type"
class="h-8 flex-1 appearance-none rounded-md border border-input bg-background px-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
@change="onTypeChange(scroller)"
>
<option v-for="t in ALL_TYPES" :key="t" :value="t">
{{ t === 'lens' ? 'Lens' : SCROLLER_LABELS[t] }}
</option>
</select>
<!-- Remove -->
<button
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 transition-colors hover:bg-destructive/10 hover:text-destructive disabled:pointer-events-none disabled:opacity-30"
:disabled="draft.length <= 1"
@click="removeScroller(index)"
>
<Trash2 :size="14" />
</button>
</div>
<!-- Lens picker (shown only when type = lens) -->
<div v-if="scroller.type === 'lens'" class="border-t border-border/50 px-3 pb-2.5 pt-2">
<label class="mb-1.5 block text-[11px] font-medium text-muted-foreground">Lens</label>
<select
v-if="lenses.length > 0"
v-model="scroller.lensId"
class="h-8 w-full appearance-none rounded-md border border-input bg-background px-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
@change="onLensChange(scroller)"
>
<option v-for="lens in lenses" :key="lens.id" :value="lens.id">{{ lens.name }}</option>
</select>
<p v-else class="text-xs text-muted-foreground">No lenses yet. Create one from the sidebar.</p>
</div>
</div>
</div>
<!-- Add shelf -->
<button
class="mt-3 flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-border py-2.5 text-sm text-muted-foreground transition-colors hover:border-primary/50 hover:text-primary disabled:pointer-events-none disabled:opacity-40"
:disabled="draft.length >= MAX_SCROLLERS"
@click="addScroller"
>
<Plus :size="15" />
Add shelf
<span class="text-xs opacity-60">({{ draft.length }}/{{ MAX_SCROLLERS }})</span>
</button>
</div>
<!-- Footer -->
<div class="flex items-center justify-between border-t border-border px-5 py-4">
<button class="flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground" @click="resetToDefault">
<RotateCcw :size="13" />
Reset to defaults
</button>
<div class="flex items-center gap-2">
<button
class="h-8 rounded-md border border-input px-3 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
@click="emit('update:open', false)"
>
Cancel
</button>
<button
class="h-8 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
@click="save"
>
Save
</button>
</div>
</div>
</SheetContent>
</Sheet>
</template>
<style scoped>
/* On pointer-fine devices (mouse), hide the up/down buttons — drag handles suffice */
@media (pointer: fine) {
.touch-reorder-btn {
display: none;
}
}
/* On touch/coarse devices, hide the drag handle since HTML5 drag doesn't work on touch */
@media (pointer: coarse) {
.drag-handle {
display: none;
}
}
</style>
@@ -0,0 +1,63 @@
import { ref } from 'vue'
import type { ScrollerConfig, ScrollerType } from '@projectx/types'
const STORAGE_KEY = 'projectx:dashboard:config'
const MAX_SCROLLERS = 6
export const DEFAULT_SCROLLERS: ScrollerConfig[] = [
{ id: '1', type: 'continue-reading', label: 'Continue Reading', enabled: true, order: 1, limit: 20 },
{ id: '2', type: 'recently-added', label: 'Recently Added', enabled: true, order: 2, limit: 20 },
{ id: '3', type: 'random', label: 'Discover Something New', enabled: true, order: 3, limit: 20 },
]
export const SCROLLER_LABELS: Record<ScrollerType, string> = {
'continue-reading': 'Continue Reading',
'recently-added': 'Recently Added',
random: 'Discover Something New',
lens: 'Lens',
}
function loadConfig(): ScrollerConfig[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? JSON.parse(raw) : DEFAULT_SCROLLERS
} catch {
return DEFAULT_SCROLLERS
}
}
// Module-level singleton — all callers share the same reactive ref
const scrollers = ref<ScrollerConfig[]>(loadConfig())
export function useDashboardConfig() {
function save() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(scrollers.value))
}
function saveScrollers(newScrollers: ScrollerConfig[]) {
scrollers.value = newScrollers.map((s, i) => ({ ...s, order: i + 1 }))
save()
}
function addScroller(type: ScrollerType) {
if (scrollers.value.length >= MAX_SCROLLERS) return
const maxId = Math.max(0, ...scrollers.value.map((s) => Number(s.id)))
scrollers.value.push({
id: String(maxId + 1),
type,
label: SCROLLER_LABELS[type],
enabled: true,
order: scrollers.value.length + 1,
limit: 20,
})
save()
}
function reset() {
scrollers.value = DEFAULT_SCROLLERS.map((s) => ({ ...s }))
localStorage.removeItem(STORAGE_KEY)
}
return { scrollers, saveScrollers, addScroller, reset, MAX_SCROLLERS }
}
@@ -0,0 +1,29 @@
import { onMounted, ref } from 'vue'
import type { BookCard, ScrollerType } from '@projectx/types'
import { api } from '@/lib/api'
export function useDashboardScroller(type: ScrollerType, limit = 20, lensId?: number) {
const books = ref<BookCard[]>([])
const loading = ref(true)
const error = ref(false)
async function load() {
loading.value = true
error.value = false
try {
const params = new URLSearchParams({ limit: String(limit) })
if (type === 'lens' && lensId) params.set('lensId', String(lensId))
const res = await api(`/api/dashboard/scrollers/${type}?${params}`)
if (!res.ok) throw new Error()
books.value = await res.json()
} catch {
error.value = true
} finally {
loading.value = false
}
}
onMounted(load)
return { books, loading, error, refresh: load }
}
@@ -13,7 +13,7 @@ export function useLens(lensId: Ref<number>) {
const hasMore = computed(() => items.value.length < total.value)
async function load(reset = false) {
if (loading.value) return
if (loading.value || !lensId.value || isNaN(lensId.value)) return
loading.value = true
if (reset) {
@@ -39,7 +39,10 @@ export function useLens(lensId: Ref<number>) {
}
}
watch(lensId, () => load(true))
watch(lensId, (id) => {
if (!id || isNaN(id)) return
load(true)
})
return { items, total, loading, hasMore, load }
}
+3 -4
View File
@@ -1,19 +1,18 @@
import { createRouter, createWebHistory } from 'vue-router'
import { registerAuthGuard } from './guards/auth.guard'
import HomeView from '@/views/HomeView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
name: 'dashboard',
component: () => import('@/views/DashboardView.vue'),
},
{
path: '/library/:id',
name: 'library',
component: HomeView,
component: () => import('@/views/HomeView.vue'),
},
{
path: '/lens/:id',
+72
View File
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Settings2 } from 'lucide-vue-next'
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'
import AppHeader from '@/components/AppHeader.vue'
import AppSidebar from '@/components/AppSidebar.vue'
import { useAuth } from '@/features/auth/composables/useAuth'
import { BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
import DashboardScroller from '@/features/dashboard/components/DashboardScroller.vue'
import DashboardSettingsSheet from '@/features/dashboard/components/DashboardSettingsSheet.vue'
import { useDashboardConfig } from '@/features/dashboard/composables/useDashboardConfig'
const { user } = useAuth()
const { scrollers } = useDashboardConfig()
const themeStore = useThemeStore()
const settingsOpen = ref(false)
const backgroundClass = computed(() => BACKGROUND_OPTIONS.find((b) => b.id === themeStore.background)?.cssClass ?? '')
const enabledScrollers = computed(() => scrollers.value.filter((s) => s.enabled).sort((a, b) => a.order - b.order))
const greeting = computed(() => {
const hour = new Date().getHours()
const base = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening'
const firstName = user.value?.name?.split(' ')[0] || user.value?.username
return firstName ? `${base}, ${firstName}` : base
})
</script>
<template>
<SidebarProvider>
<AppSidebar />
<SidebarInset class="flex flex-col min-h-screen glow-wrapper">
<AppHeader />
<main class="flex-1 overflow-y-auto" :class="backgroundClass">
<!-- Greeting -->
<div class="flex items-center justify-between px-6 pb-4 pt-6">
<h1 class="text-xl font-semibold tracking-tight">{{ greeting }}</h1>
<button
class="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title="Customize dashboard"
@click="settingsOpen = true"
>
<Settings2 :size="16" />
</button>
</div>
<!-- Scrollers -->
<div class="space-y-5 px-4 pb-8 sm:px-6">
<DashboardScroller
v-for="scroller in enabledScrollers"
:key="`${scroller.id}-${scroller.type}-${scroller.lensId ?? 0}`"
:type="scroller.type"
:title="scroller.label"
:limit="scroller.limit"
:lens-id="scroller.lensId"
/>
<div v-if="enabledScrollers.length === 0" class="px-2 py-12 text-center">
<p class="text-sm text-muted-foreground">All shelves are hidden.</p>
<button class="mt-2 text-sm text-primary hover:underline" @click="settingsOpen = true">Customize dashboard</button>
</div>
</div>
</main>
</SidebarInset>
</SidebarProvider>
<DashboardSettingsSheet v-model:open="settingsOpen" />
</template>
+2 -8
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRoute } from 'vue-router'
import { ArrowUpDown, Bookmark, BookmarkCheck, Filter, X } from 'lucide-vue-next'
import BookCoverCard from '@/features/book/components/BookCoverCard.vue'
import BookListRow from '@/features/book/components/BookListRow.vue'
@@ -27,7 +27,6 @@ import { SORT_FIELD_LABELS } from '@/features/book/lib/filter-labels'
import type { GroupRule, SortSpec } from '@projectx/types'
const route = useRoute()
const router = useRouter()
const themeStore = useThemeStore()
const backgroundClass = computed(() => BACKGROUND_OPTIONS.find((b) => b.id === themeStore.background)?.cssClass ?? '')
const { coverSize, gridGap, viewMode } = useDisplaySettings()
@@ -165,12 +164,7 @@ function loadIfSentinelVisible() {
onMounted(async () => {
await fetchLibraries()
if (!libraryId.value && libraries.value.length > 0) {
router.replace({ name: 'library', params: { id: libraries.value[0]!.id } })
} else {
load(true)
}
load(true)
observer = new IntersectionObserver(
(entries) => {