mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(stats): add dashboard with horizontal scrollers and settings panel
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user