Files
bookorbit/client/src/features/statistics/components/StatisticsPage.vue
T

221 lines
7.9 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import {
CalendarDays,
Check,
ChevronDown,
Globe,
GripVertical,
HardDrive,
ListChecks,
PieChart,
Settings2,
Tag,
TrendingUp,
Users,
} from 'lucide-vue-next'
import { VueDraggable } from 'vue-draggable-plus'
import type { ChartConfigEntry, StatisticsChartId } from '@projectx/types'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { useStatisticsConfig } from '../composables/useStatisticsConfig'
import StatisticsGrid from './StatisticsGrid.vue'
import StatisticsSummaryCard from './StatisticsSummaryCard.vue'
const CHART_LABELS: Record<StatisticsChartId, string> = {
'format-distribution': 'Format Distribution',
'language-distribution': 'Language Distribution',
'books-added-over-time': 'Books Added Over Time',
'storage-by-format': 'Storage by Format',
'publication-decade': 'Publication Decade',
'top-authors': 'Top 25 Authors',
'metadata-completeness': 'Metadata Completeness',
'genre-distribution': 'Genre Distribution',
}
const CHART_ICONS: Record<StatisticsChartId, unknown> = {
'format-distribution': PieChart,
'language-distribution': Globe,
'books-added-over-time': TrendingUp,
'storage-by-format': HardDrive,
'publication-decade': CalendarDays,
'top-authors': Users,
'metadata-completeness': ListChecks,
'genre-distribution': Tag,
}
const { orderedCharts, filters, init, toggleVisibility, reorder, resetToDefaults, setLibraryFilter } = useStatisticsConfig()
const { libraries, fetchLibraries } = useLibraries()
const configOpen = ref(false)
// Called in setup (not onMounted) so filters are populated before chart children mount.
init()
onMounted(fetchLibraries)
const visibleCount = computed(() => orderedCharts.value.filter((c) => c.visible).length)
const totalCount = computed(() => orderedCharts.value.length)
const libraryLabel = computed(() => {
const count = filters.value.libraryIds.length
if (count === 0 || count === libraries.value.length) return 'All Libraries'
return `${count} of ${libraries.value.length} Libraries`
})
const isFiltered = computed(() => filters.value.libraryIds.length > 0 && filters.value.libraryIds.length < libraries.value.length)
function isLibrarySelected(id: number) {
return filters.value.libraryIds.length === 0 || filters.value.libraryIds.includes(id)
}
function handleToggleLibrary(id: number) {
const current = filters.value.libraryIds
if (current.length === 0) {
setLibraryFilter(libraries.value.map((l) => l.id).filter((lid) => lid !== id))
} else if (current.includes(id)) {
const next = current.filter((lid) => lid !== id)
setLibraryFilter(next.length === 0 ? [] : next)
} else {
const next = [...current, id]
setLibraryFilter(next.length === libraries.value.length ? [] : next)
}
}
function handleSelectAll() {
setLibraryFilter([])
}
function handleToggleChart(id: StatisticsChartId) {
toggleVisibility(id)
}
function handleReorder(newList: ChartConfigEntry[]) {
reorder(newList)
}
function handleReset() {
resetToDefaults()
}
function openConfig() {
configOpen.value = true
}
</script>
<template>
<div class="flex flex-col gap-6 pt-4">
<div class="flex items-center justify-between">
<div>
<h1 class="text-foreground text-[22px] font-semibold">Library Statistics</h1>
</div>
<div class="flex items-center gap-2">
<Popover v-if="libraries.length > 1">
<PopoverTrigger as-child>
<button
:class="[
'flex h-8 items-center gap-1.5 rounded-md border px-3 text-sm transition-colors',
isFiltered ? 'border-primary/40 bg-primary/10 text-primary hover:bg-primary/15' : 'border-border text-foreground hover:bg-accent',
]"
>
{{ libraryLabel }}
<ChevronDown class="size-3.5 opacity-60" />
</button>
</PopoverTrigger>
<PopoverContent align="end" class="w-56 p-1.5">
<button
class="text-muted-foreground hover:bg-accent hover:text-foreground flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm"
@click="handleSelectAll"
>
<div :class="['flex size-4 items-center justify-center rounded border', !isFiltered ? 'border-primary bg-primary' : 'border-border']">
<Check v-if="!isFiltered" class="text-primary-foreground size-2.5" />
</div>
All Libraries
</button>
<div class="border-border my-1 border-t" />
<button
v-for="lib in libraries"
:key="lib.id"
class="hover:bg-accent flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm"
@click="handleToggleLibrary(lib.id)"
>
<div
:class="[
'flex size-4 shrink-0 items-center justify-center rounded border',
isLibrarySelected(lib.id) ? 'border-primary bg-primary' : 'border-border',
]"
>
<Check v-if="isLibrarySelected(lib.id)" class="text-primary-foreground size-2.5" />
</div>
<span class="truncate">{{ lib.name }}</span>
</button>
</PopoverContent>
</Popover>
<button
class="bg-muted text-foreground hover:bg-muted/70 flex h-8 items-center gap-1.5 rounded-md px-2.5 text-sm font-medium transition-colors"
@click="openConfig"
>
<Settings2 class="size-3.5" />
Configure
</button>
</div>
</div>
<StatisticsSummaryCard />
<Sheet v-model:open="configOpen">
<SheetContent side="right" class="w-[420px] sm:max-w-[420px]">
<SheetHeader>
<div class="flex items-center justify-between pr-8">
<SheetTitle>Configure Charts</SheetTitle>
<span class="bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-xs font-medium tabular-nums">
{{ visibleCount }} / {{ totalCount }}
</span>
</div>
<SheetDescription>Drag to reorder, toggle to show or hide.</SheetDescription>
</SheetHeader>
<div class="flex-1 overflow-y-auto px-4">
<VueDraggable
:model-value="orderedCharts"
class="flex flex-col gap-2"
handle=".drawer-drag-handle"
:animation="150"
@update:model-value="handleReorder"
>
<div
v-for="chart in orderedCharts"
:key="chart.id"
:class="[
'border-border/50 bg-muted/40 flex items-center gap-3 rounded-lg border px-3 py-2.5 transition-opacity',
!chart.visible && 'opacity-50',
]"
>
<GripVertical class="text-muted-foreground/80 drawer-drag-handle size-4 shrink-0 cursor-grab active:cursor-grabbing" />
<component :is="CHART_ICONS[chart.id]" :class="['size-4 shrink-0', chart.visible ? 'text-primary' : 'text-muted-foreground']" />
<span :class="['flex-1 text-sm', chart.visible ? 'text-foreground' : 'text-muted-foreground']">
{{ CHART_LABELS[chart.id] }}
</span>
<ToggleSwitch :model-value="chart.visible" @update:model-value="handleToggleChart(chart.id)" />
</div>
</VueDraggable>
</div>
<SheetFooter>
<button
class="text-muted-foreground hover:text-foreground w-full rounded-md py-2 text-sm transition-colors hover:bg-transparent"
@click="handleReset"
>
Reset to defaults
</button>
</SheetFooter>
</SheetContent>
</Sheet>
<StatisticsGrid />
</div>
</template>