mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(stats): introduce statistics with charts, KPI summary, and filter
This commit is contained in:
+3
-1
@@ -29,6 +29,7 @@
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"echarts": "^6.0.0",
|
||||
"lucide-vue-next": "^0.577.0",
|
||||
"pdfjs-dist": "^5.5.207",
|
||||
"pinia": "^3.0.4",
|
||||
@@ -38,6 +39,7 @@
|
||||
"tailwindcss": "^4.2.1",
|
||||
"vue": "^3.5.30",
|
||||
"vue-draggable-plus": "^0.6.1",
|
||||
"vue-echarts": "^8.0.1",
|
||||
"vue-router": "^5.0.3",
|
||||
"vue-sonner": "^2.0.9",
|
||||
"vue-virtual-scroller": "^1.1.2"
|
||||
@@ -66,7 +68,7 @@
|
||||
"vite": "^8.0.0",
|
||||
"vite-plugin-vue-devtools": "^8.1.0",
|
||||
"vitest": "^4.1.0",
|
||||
"vue-tsc": "^3.2.5"
|
||||
"vue-tsc": "^3.2.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
|
||||
+22
-1
@@ -1,12 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, provide, ref, watch } from 'vue'
|
||||
import { INIT_OPTIONS_KEY, THEME_KEY } from 'vue-echarts'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
|
||||
import { initChartThemes } from '@/lib/echarts'
|
||||
import ChangePasswordDialog from '@/features/auth/ChangePasswordDialog.vue'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
useThemeStore()
|
||||
const themeStore = useThemeStore()
|
||||
const { isOpen } = useChangePasswordDialog()
|
||||
|
||||
const themeVersion = ref(0)
|
||||
|
||||
provide(INIT_OPTIONS_KEY, { renderer: 'svg' })
|
||||
provide(
|
||||
THEME_KEY,
|
||||
computed(() => `projectx-v${themeVersion.value}`),
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
initChartThemes(0)
|
||||
})
|
||||
|
||||
watch([() => themeStore.theme, () => themeStore.accent], () => {
|
||||
const v = themeVersion.value + 1
|
||||
initChartThemes(v)
|
||||
themeVersion.value = v
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -52,11 +52,25 @@
|
||||
--sidebar-border: oklch(0.878 var(--tint-c-surface) var(--tint-h));
|
||||
--sidebar-ring: oklch(0.65 var(--tint-c-surface) var(--tint-h));
|
||||
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--chart-1: oklch(0.62 0.16 var(--tint-h));
|
||||
--chart-2: oklch(0.6 0.15 calc(var(--tint-h) + 72));
|
||||
--chart-3: oklch(0.58 0.14 calc(var(--tint-h) + 144));
|
||||
--chart-4: oklch(0.64 0.15 calc(var(--tint-h) + 216));
|
||||
--chart-5: oklch(0.62 0.16 calc(var(--tint-h) + 288));
|
||||
--chart-6: oklch(0.44 0.14 calc(var(--tint-h) + 36));
|
||||
--chart-7: oklch(0.42 0.13 calc(var(--tint-h) + 108));
|
||||
--chart-8: oklch(0.4 0.12 calc(var(--tint-h) + 180));
|
||||
--chart-9: oklch(0.46 0.13 calc(var(--tint-h) + 252));
|
||||
--chart-10: oklch(0.43 0.14 calc(var(--tint-h) + 324));
|
||||
--chart-icon-1: oklch(0.58 0.17 var(--tint-h));
|
||||
--chart-icon-2: oklch(0.58 0.17 calc(var(--tint-h) + 45));
|
||||
--chart-icon-3: oklch(0.58 0.17 calc(var(--tint-h) + 90));
|
||||
--chart-icon-4: oklch(0.58 0.17 calc(var(--tint-h) + 135));
|
||||
--chart-icon-5: oklch(0.58 0.17 calc(var(--tint-h) + 180));
|
||||
--chart-icon-6: oklch(0.58 0.17 calc(var(--tint-h) + 225));
|
||||
--chart-icon-7: oklch(0.58 0.17 calc(var(--tint-h) + 270));
|
||||
--chart-icon-8: oklch(0.58 0.17 calc(var(--tint-h) + 315));
|
||||
--chart-icon-9: oklch(0.58 0.17 calc(var(--tint-h) + 337));
|
||||
|
||||
/* Elevation surfaces */
|
||||
--surface-1: oklch(0.975 var(--tint-c-surface) var(--tint-h));
|
||||
@@ -120,11 +134,24 @@
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.52 0.008 var(--tint-h));
|
||||
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--chart-1: oklch(0.7 0.16 var(--tint-h));
|
||||
--chart-2: oklch(0.68 0.15 calc(var(--tint-h) + 72));
|
||||
--chart-3: oklch(0.66 0.14 calc(var(--tint-h) + 144));
|
||||
--chart-4: oklch(0.72 0.15 calc(var(--tint-h) + 216));
|
||||
--chart-5: oklch(0.7 0.16 calc(var(--tint-h) + 288));
|
||||
--chart-6: oklch(0.52 0.14 calc(var(--tint-h) + 36));
|
||||
--chart-7: oklch(0.5 0.13 calc(var(--tint-h) + 108));
|
||||
--chart-8: oklch(0.48 0.12 calc(var(--tint-h) + 180));
|
||||
--chart-9: oklch(0.54 0.13 calc(var(--tint-h) + 252));
|
||||
--chart-10: oklch(0.51 0.14 calc(var(--tint-h) + 324));
|
||||
--chart-icon-1: oklch(0.7 0.17 var(--tint-h));
|
||||
--chart-icon-2: oklch(0.7 0.17 calc(var(--tint-h) + 45));
|
||||
--chart-icon-3: oklch(0.7 0.17 calc(var(--tint-h) + 90));
|
||||
--chart-icon-4: oklch(0.7 0.17 calc(var(--tint-h) + 135));
|
||||
--chart-icon-5: oklch(0.7 0.17 calc(var(--tint-h) + 180));
|
||||
--chart-icon-6: oklch(0.7 0.17 calc(var(--tint-h) + 225));
|
||||
--chart-icon-7: oklch(0.7 0.17 calc(var(--tint-h) + 270));
|
||||
--chart-icon-8: oklch(0.7 0.17 calc(var(--tint-h) + 315));
|
||||
|
||||
/* Elevation surfaces */
|
||||
--surface-1: oklch(calc(0.205 + var(--bg-lift)) var(--tint-c-surface) var(--tint-h));
|
||||
|
||||
@@ -86,6 +86,7 @@ const {
|
||||
|
||||
const isDashboardActive = computed(() => route.name === 'dashboard')
|
||||
const isStagingActive = computed(() => route.name === 'staging')
|
||||
const isStatisticsActive = computed(() => route.name === 'statistics')
|
||||
const isAuthorsActive = computed(() => route.name === 'authors' || route.name === 'author-detail')
|
||||
|
||||
const activeLibraryId = computed(() => {
|
||||
@@ -222,6 +223,13 @@ onUnmounted(() => stopUploadCompletedListener())
|
||||
label="Authors"
|
||||
@click="router.push({ name: 'authors' })"
|
||||
/>
|
||||
<SidebarNavItem
|
||||
:is-active="isStatisticsActive"
|
||||
tooltip="Statistics"
|
||||
:icon="Icons.BarChart3"
|
||||
label="Statistics"
|
||||
@click="router.push({ name: 'statistics' })"
|
||||
/>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { api } from '@/lib/api'
|
||||
import type {
|
||||
BooksAddedDataPoint,
|
||||
StatisticsSummary,
|
||||
FormatDistributionItem,
|
||||
GenreDistributionItem,
|
||||
LanguageDistributionItem,
|
||||
MetadataCompletenessItem,
|
||||
PublicationDecadeItem,
|
||||
StatisticsFilterConfig,
|
||||
StatisticsResult,
|
||||
StorageByFormatItem,
|
||||
TopAuthorItem,
|
||||
} from '@projectx/types'
|
||||
|
||||
async function parseResult<T>(res: Response): Promise<StatisticsResult<T>> {
|
||||
if (!res.ok) throw new Error(`Statistics request failed: ${res.status}`)
|
||||
return res.json() as Promise<StatisticsResult<T>>
|
||||
}
|
||||
|
||||
function buildParams(filters: StatisticsFilterConfig, extra?: Record<string, string>): string {
|
||||
const params = new URLSearchParams()
|
||||
filters.libraryIds.forEach((id) => params.append('libraryIds', String(id)))
|
||||
if (extra) Object.entries(extra).forEach(([k, v]) => params.set(k, v))
|
||||
const str = params.toString()
|
||||
return str ? `?${str}` : ''
|
||||
}
|
||||
|
||||
export async function fetchFormatDistribution(filters: StatisticsFilterConfig): Promise<StatisticsResult<FormatDistributionItem>> {
|
||||
return parseResult(await api(`/api/v1/statistics/format-distribution${buildParams(filters)}`))
|
||||
}
|
||||
|
||||
export async function fetchLanguageDistribution(filters: StatisticsFilterConfig): Promise<StatisticsResult<LanguageDistributionItem>> {
|
||||
return parseResult(await api(`/api/v1/statistics/language-distribution${buildParams(filters)}`))
|
||||
}
|
||||
|
||||
export async function fetchBooksAddedOverTime(filters: StatisticsFilterConfig): Promise<StatisticsResult<BooksAddedDataPoint>> {
|
||||
return parseResult(
|
||||
await api(
|
||||
`/api/v1/statistics/books-added-over-time${buildParams(filters, {
|
||||
granularity: filters.booksOverTimeGranularity,
|
||||
range: filters.booksOverTimeRange,
|
||||
})}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchStorageByFormat(filters: StatisticsFilterConfig): Promise<StatisticsResult<StorageByFormatItem>> {
|
||||
return parseResult(await api(`/api/v1/statistics/storage-by-format${buildParams(filters)}`))
|
||||
}
|
||||
|
||||
export async function fetchPublicationDecade(filters: StatisticsFilterConfig): Promise<StatisticsResult<PublicationDecadeItem>> {
|
||||
return parseResult(await api(`/api/v1/statistics/publication-decade${buildParams(filters)}`))
|
||||
}
|
||||
|
||||
export async function fetchTopAuthors(filters: StatisticsFilterConfig): Promise<StatisticsResult<TopAuthorItem>> {
|
||||
return parseResult(await api(`/api/v1/statistics/top-authors${buildParams(filters)}`))
|
||||
}
|
||||
|
||||
export async function fetchMetadataCompleteness(filters: StatisticsFilterConfig): Promise<StatisticsResult<MetadataCompletenessItem>> {
|
||||
return parseResult(await api(`/api/v1/statistics/metadata-completeness${buildParams(filters)}`))
|
||||
}
|
||||
|
||||
export async function fetchGenreDistribution(filters: StatisticsFilterConfig): Promise<StatisticsResult<GenreDistributionItem>> {
|
||||
return parseResult(await api(`/api/v1/statistics/genre-distribution${buildParams(filters)}`))
|
||||
}
|
||||
|
||||
export async function fetchStatisticsSummary(filters: StatisticsFilterConfig): Promise<StatisticsSummary> {
|
||||
const res = await api(`/api/v1/statistics/summary${buildParams(filters)}`)
|
||||
if (!res.ok) throw new Error(`Statistics summary request failed: ${res.status}`)
|
||||
return res.json() as Promise<StatisticsSummary>
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import { shallowRef, watchEffect } from 'vue'
|
||||
import VChart from 'vue-echarts'
|
||||
import { TrendingUp } from 'lucide-vue-next'
|
||||
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { useBooksAddedOverTime } from '../composables/useBooksAddedOverTime'
|
||||
import { useStatisticsConfig } from '../composables/useStatisticsConfig'
|
||||
import ChartCard from './ChartCard.vue'
|
||||
|
||||
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
||||
|
||||
const DATE_RANGE_LABELS = {
|
||||
'all-time': 'All Time',
|
||||
'last-5-years': 'Last 5 Years',
|
||||
'last-year': 'Last Year',
|
||||
} as const
|
||||
|
||||
const { data, loading, error } = useBooksAddedOverTime()
|
||||
const { filters, setGranularity, setDateRange } = useStatisticsConfig()
|
||||
|
||||
const option = shallowRef({})
|
||||
|
||||
watchEffect(() => {
|
||||
if (!data.value.items.length) return
|
||||
|
||||
const isYearly = filters.value.booksOverTimeGranularity === 'yearly'
|
||||
const labels = data.value.items.map((d) => (isYearly ? String(d.year) : `${MONTH_NAMES[d.month - 1]} ${d.year}`))
|
||||
|
||||
option.value = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
confine: true,
|
||||
enterable: false,
|
||||
formatter: (params: { name: string; value: number }[]) => {
|
||||
const p = params[0]
|
||||
if (!p) return ''
|
||||
return `${p.name}: <strong>${p.value}</strong> books`
|
||||
},
|
||||
},
|
||||
grid: { left: '3%', right: '4%', bottom: '12%', top: '8%', containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: labels,
|
||||
axisTick: { show: false },
|
||||
axisLabel: {
|
||||
fontSize: 11,
|
||||
rotate: 45,
|
||||
interval: Math.max(0, Math.floor(data.value.items.length / 8) - 1),
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
minInterval: 1,
|
||||
axisLabel: { fontSize: 11 },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: data.value.items.map((d) => d.count),
|
||||
cursor: 'default',
|
||||
itemStyle: { borderRadius: [3, 3, 0, 0] },
|
||||
barMaxWidth: 40,
|
||||
emphasis: { disabled: true },
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
function handleMonthly() {
|
||||
setGranularity('monthly')
|
||||
}
|
||||
|
||||
function handleYearly() {
|
||||
setGranularity('yearly')
|
||||
}
|
||||
|
||||
function handleAllTime() {
|
||||
setDateRange('all-time')
|
||||
}
|
||||
|
||||
function handleLast5Years() {
|
||||
setDateRange('last-5-years')
|
||||
}
|
||||
|
||||
function handleLastYear() {
|
||||
setDateRange('last-year')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ChartCard title="Books Added Over Time" :icon="TrendingUp" :color-index="3" :loading :error :empty="!data.items.length">
|
||||
<template #controls>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="border-border flex rounded-md border text-xs">
|
||||
<button
|
||||
:class="[
|
||||
'rounded-l-md px-2 py-1 transition-colors',
|
||||
filters.booksOverTimeGranularity === 'monthly' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground',
|
||||
]"
|
||||
@click="handleMonthly"
|
||||
>
|
||||
Monthly
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'rounded-r-md px-2 py-1 transition-colors',
|
||||
filters.booksOverTimeGranularity === 'yearly' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground',
|
||||
]"
|
||||
@click="handleYearly"
|
||||
>
|
||||
Yearly
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button class="border-border text-muted-foreground hover:text-foreground rounded-md border px-2 py-1 text-xs transition-colors">
|
||||
{{ DATE_RANGE_LABELS[filters.booksOverTimeRange] }}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="text-xs">
|
||||
<DropdownMenuItem @click="handleAllTime">All Time</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="handleLast5Years">Last 5 Years</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="handleLastYear">Last Year</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<VChart :option autoresize style="height: 100%" />
|
||||
</ChartCard>
|
||||
</template>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import { AlertCircle, GripVertical } from 'lucide-vue-next'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
icon: Component
|
||||
colorIndex: number
|
||||
loading: boolean
|
||||
empty: boolean
|
||||
unknownCount?: number
|
||||
error?: boolean
|
||||
tall?: boolean
|
||||
short?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'bg-card text-card-foreground flex flex-col overflow-hidden rounded-xl border shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
||||
tall ? 'h-[560px] md:h-[640px]' : short ? 'h-[240px] md:h-[280px]' : 'h-[320px] md:h-[360px]',
|
||||
]"
|
||||
>
|
||||
<div class="flex min-h-0 flex-1 flex-col p-4">
|
||||
<div class="mb-3 flex items-center justify-between gap-2 border-b pb-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div
|
||||
class="shrink-0 rounded-md p-2"
|
||||
:style="{
|
||||
backgroundColor: `color-mix(in oklch, var(--chart-icon-${colorIndex}) 15%, transparent)`,
|
||||
color: `var(--chart-icon-${colorIndex})`,
|
||||
}"
|
||||
>
|
||||
<component :is="icon" class="size-4" />
|
||||
</div>
|
||||
<p class="text-foreground text-sm font-semibold">{{ title }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<slot name="controls" />
|
||||
<GripVertical class="drag-handle text-muted-foreground/50 hover:text-muted-foreground size-4 cursor-grab active:cursor-grabbing" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1">
|
||||
<div v-if="loading" class="flex h-full flex-col gap-2">
|
||||
<Skeleton class="h-full w-full rounded-lg" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="text-muted-foreground flex h-full flex-col items-center justify-center gap-2">
|
||||
<AlertCircle class="size-6" />
|
||||
<p class="text-sm">Failed to load data</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="empty" class="text-muted-foreground flex h-full flex-col items-center justify-center gap-3">
|
||||
<component :is="icon" class="size-9 opacity-20" />
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<p class="text-sm font-medium">No data yet</p>
|
||||
<p class="text-xs opacity-60">No data available for this chart</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<slot v-else />
|
||||
</div>
|
||||
|
||||
<p v-if="!loading && !error && unknownCount && unknownCount > 0" class="text-muted-foreground mt-2 text-xs">
|
||||
{{ unknownCount }} {{ unknownCount === 1 ? 'book has' : 'books have' }} no data for this field
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { shallowRef, watchEffect } from 'vue'
|
||||
import VChart from 'vue-echarts'
|
||||
import { PieChart } from 'lucide-vue-next'
|
||||
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
||||
|
||||
import { useFormatDistribution } from '../composables/useFormatDistribution'
|
||||
import ChartCard from './ChartCard.vue'
|
||||
|
||||
const { data, loading, error } = useFormatDistribution()
|
||||
const { md } = useBreakpoints(breakpointsTailwind)
|
||||
|
||||
const option = shallowRef({})
|
||||
|
||||
watchEffect(() => {
|
||||
if (!data.value.items.length) return
|
||||
option.value = {
|
||||
tooltip: { trigger: 'item', confine: true, enterable: false },
|
||||
legend: {
|
||||
orient: md.value ? 'vertical' : 'horizontal',
|
||||
right: md.value ? '2%' : 'auto',
|
||||
bottom: md.value ? 'auto' : 0,
|
||||
top: md.value ? 'middle' : 'auto',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
center: md.value ? ['38%', '50%'] : ['50%', '44%'],
|
||||
data: data.value.items.map((item) => ({ name: item.format.toUpperCase(), value: item.count })),
|
||||
cursor: 'default',
|
||||
label: { show: false },
|
||||
emphasis: { disabled: true },
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ChartCard
|
||||
title="Format Distribution"
|
||||
:icon="PieChart"
|
||||
:color-index="1"
|
||||
:loading
|
||||
:error
|
||||
:empty="!data.items.length"
|
||||
:unknown-count="data.unknownCount"
|
||||
>
|
||||
<VChart :option autoresize style="height: 100%" />
|
||||
</ChartCard>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { shallowRef, watchEffect } from 'vue'
|
||||
import VChart from 'vue-echarts'
|
||||
import { Tag } from 'lucide-vue-next'
|
||||
|
||||
import { useGenreDistribution } from '../composables/useGenreDistribution'
|
||||
import ChartCard from './ChartCard.vue'
|
||||
|
||||
const { data, loading, error } = useGenreDistribution()
|
||||
const option = shallowRef({})
|
||||
|
||||
watchEffect(() => {
|
||||
if (!data.value.items.length) return
|
||||
option.value = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
confine: true,
|
||||
enterable: false,
|
||||
formatter: (params: { name: string; value: number }[]) => {
|
||||
const p = params[0]
|
||||
if (!p) return ''
|
||||
return `${p.name}: <strong>${p.value}</strong> books`
|
||||
},
|
||||
},
|
||||
grid: { left: '3%', right: '3%', bottom: '28%', top: '8%', containLabel: false },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.value.items.map((d) => d.genre),
|
||||
axisTick: { show: false },
|
||||
axisLabel: {
|
||||
fontSize: 11,
|
||||
rotate: 45,
|
||||
interval: 0,
|
||||
overflow: 'truncate',
|
||||
width: 80,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
minInterval: 1,
|
||||
axisLabel: { fontSize: 11 },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: data.value.items.map((d) => d.count),
|
||||
cursor: 'default',
|
||||
itemStyle: { borderRadius: [3, 3, 0, 0] },
|
||||
barMaxWidth: 32,
|
||||
emphasis: { disabled: true },
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ChartCard title="Genre Distribution" :icon="Tag" :color-index="8" :loading :error :empty="!data.items.length" :unknown-count="data.unknownCount">
|
||||
<VChart :option autoresize style="height: 100%" />
|
||||
</ChartCard>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { shallowRef, watchEffect } from 'vue'
|
||||
import VChart from 'vue-echarts'
|
||||
import { Globe } from 'lucide-vue-next'
|
||||
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
||||
|
||||
import { useLanguageDistribution } from '../composables/useLanguageDistribution'
|
||||
import ChartCard from './ChartCard.vue'
|
||||
|
||||
const { data, loading, error } = useLanguageDistribution()
|
||||
const { md } = useBreakpoints(breakpointsTailwind)
|
||||
|
||||
const option = shallowRef({})
|
||||
|
||||
watchEffect(() => {
|
||||
if (!data.value.items.length) return
|
||||
option.value = {
|
||||
tooltip: { trigger: 'item', confine: true, enterable: false },
|
||||
legend: {
|
||||
orient: md.value ? 'vertical' : 'horizontal',
|
||||
right: md.value ? '2%' : 'auto',
|
||||
bottom: md.value ? 'auto' : 0,
|
||||
top: md.value ? 'middle' : 'auto',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
center: md.value ? ['38%', '50%'] : ['50%', '44%'],
|
||||
data: data.value.items.map((item) => ({ name: item.language.toUpperCase(), value: item.count })),
|
||||
cursor: 'default',
|
||||
label: { show: false },
|
||||
emphasis: { disabled: true },
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ChartCard
|
||||
title="Language Distribution"
|
||||
:icon="Globe"
|
||||
:color-index="2"
|
||||
:loading
|
||||
:error
|
||||
:empty="!data.items.length"
|
||||
:unknown-count="data.unknownCount"
|
||||
>
|
||||
<VChart :option autoresize style="height: 100%" />
|
||||
</ChartCard>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { shallowRef, watchEffect } from 'vue'
|
||||
import VChart from 'vue-echarts'
|
||||
import { ListChecks } from 'lucide-vue-next'
|
||||
|
||||
import { useMetadataCompleteness } from '../composables/useMetadataCompleteness'
|
||||
import ChartCard from './ChartCard.vue'
|
||||
|
||||
const { data, loading, error } = useMetadataCompleteness()
|
||||
const option = shallowRef({})
|
||||
|
||||
watchEffect(() => {
|
||||
if (!data.value.items.length) return
|
||||
option.value = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
confine: true,
|
||||
enterable: false,
|
||||
formatter: (params: { name: string; value: number }[]) => {
|
||||
const p = params[0]
|
||||
if (!p) return ''
|
||||
const item = data.value.items.find((i) => i.field === p.name)
|
||||
if (!item) return ''
|
||||
return `${p.name}: <strong>${p.value}%</strong> (${item.presentCount} / ${item.totalCount})`
|
||||
},
|
||||
},
|
||||
grid: { left: '3%', right: '3%', bottom: '24%', top: '8%', containLabel: false },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: [...data.value.items].reverse().map((d) => d.field),
|
||||
axisTick: { show: false },
|
||||
axisLabel: {
|
||||
fontSize: 11,
|
||||
rotate: 45,
|
||||
interval: 0,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: 100,
|
||||
axisLabel: { fontSize: 11, formatter: '{value}%' },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: [...data.value.items].reverse().map((d) => (d.totalCount > 0 ? Math.round((d.presentCount / d.totalCount) * 100) : 0)),
|
||||
cursor: 'default',
|
||||
itemStyle: { borderRadius: [3, 3, 0, 0] },
|
||||
barMaxWidth: 40,
|
||||
emphasis: { disabled: true },
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ChartCard title="Metadata Completeness" :icon="ListChecks" :color-index="7" :loading :error :empty="!data.items.length">
|
||||
<VChart :option autoresize style="height: 100%" />
|
||||
</ChartCard>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { shallowRef, watchEffect } from 'vue'
|
||||
import VChart from 'vue-echarts'
|
||||
import { CalendarDays } from 'lucide-vue-next'
|
||||
|
||||
import { usePublicationDecade } from '../composables/usePublicationDecade'
|
||||
import ChartCard from './ChartCard.vue'
|
||||
|
||||
const { data, loading, error } = usePublicationDecade()
|
||||
const option = shallowRef({})
|
||||
|
||||
watchEffect(() => {
|
||||
if (!data.value.items.length) return
|
||||
option.value = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
confine: true,
|
||||
enterable: false,
|
||||
formatter: (params: { name: string; value: number }[]) => {
|
||||
const p = params[0]
|
||||
if (!p) return ''
|
||||
return `${p.name}: <strong>${p.value}</strong> books`
|
||||
},
|
||||
},
|
||||
grid: { left: '3%', right: '4%', bottom: '12%', top: '8%', containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.value.items.map((d) => `${d.decade}s`),
|
||||
axisTick: { show: false },
|
||||
axisLabel: { fontSize: 11 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
minInterval: 1,
|
||||
axisLabel: { fontSize: 11 },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: data.value.items.map((d) => d.count),
|
||||
cursor: 'default',
|
||||
itemStyle: { borderRadius: [3, 3, 0, 0] },
|
||||
barMaxWidth: 48,
|
||||
emphasis: { disabled: true },
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ChartCard
|
||||
title="Publication Decade"
|
||||
:icon="CalendarDays"
|
||||
:color-index="5"
|
||||
:loading
|
||||
:error
|
||||
:empty="!data.items.length"
|
||||
:unknown-count="data.unknownCount"
|
||||
>
|
||||
<VChart :option autoresize style="height: 100%" />
|
||||
</ChartCard>
|
||||
</template>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { type Component, defineAsyncComponent } from 'vue'
|
||||
import { VueDraggable } from 'vue-draggable-plus'
|
||||
|
||||
import type { ChartConfigEntry, StatisticsChartId } from '@projectx/types'
|
||||
import { useStatisticsConfig } from '../composables/useStatisticsConfig'
|
||||
|
||||
interface ChartRegistryEntry {
|
||||
component: Component
|
||||
label: string
|
||||
wide: boolean
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
const CHART_REGISTRY: Record<StatisticsChartId, ChartRegistryEntry> = {
|
||||
'format-distribution': {
|
||||
component: defineAsyncComponent(() => import('./FormatDistributionChart.vue')),
|
||||
label: 'Format Distribution',
|
||||
wide: false,
|
||||
},
|
||||
'language-distribution': {
|
||||
component: defineAsyncComponent(() => import('./LanguageDistributionChart.vue')),
|
||||
label: 'Language Distribution',
|
||||
wide: false,
|
||||
},
|
||||
'books-added-over-time': {
|
||||
component: defineAsyncComponent(() => import('./BooksAddedOverTimeChart.vue')),
|
||||
label: 'Books Added Over Time',
|
||||
wide: true,
|
||||
},
|
||||
'storage-by-format': {
|
||||
component: defineAsyncComponent(() => import('./StorageByFormatChart.vue')),
|
||||
label: 'Storage by Format',
|
||||
wide: false,
|
||||
},
|
||||
'publication-decade': {
|
||||
component: defineAsyncComponent(() => import('./PublicationDecadeChart.vue')),
|
||||
label: 'Publication Decade',
|
||||
wide: false,
|
||||
},
|
||||
'top-authors': {
|
||||
component: defineAsyncComponent(() => import('./TopAuthorsChart.vue')),
|
||||
label: 'Top Authors',
|
||||
wide: true,
|
||||
},
|
||||
'metadata-completeness': {
|
||||
component: defineAsyncComponent(() => import('./MetadataCompletenessChart.vue')),
|
||||
label: 'Metadata Completeness',
|
||||
wide: false,
|
||||
},
|
||||
'genre-distribution': {
|
||||
component: defineAsyncComponent(() => import('./GenreDistributionChart.vue')),
|
||||
label: 'Genre Distribution',
|
||||
wide: true,
|
||||
},
|
||||
}
|
||||
|
||||
const { visibleCharts, reorder } = useStatisticsConfig()
|
||||
|
||||
function handleReorder(newList: ChartConfigEntry[]) {
|
||||
reorder(newList)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VueDraggable
|
||||
:model-value="visibleCharts"
|
||||
class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
|
||||
handle=".drag-handle"
|
||||
:animation="200"
|
||||
@update:model-value="handleReorder"
|
||||
>
|
||||
<div
|
||||
v-for="chart in visibleCharts"
|
||||
:key="chart.id"
|
||||
:class="
|
||||
CHART_REGISTRY[chart.id].fullWidth ? 'md:col-span-2 xl:col-span-3 2xl:col-span-4' : CHART_REGISTRY[chart.id].wide ? 'md:col-span-2' : ''
|
||||
"
|
||||
>
|
||||
<component :is="CHART_REGISTRY[chart.id].component" />
|
||||
</div>
|
||||
</VueDraggable>
|
||||
</template>
|
||||
@@ -0,0 +1,220 @@
|
||||
<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/60 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>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { BarChart3, BookOpen, Building2, CalendarPlus, CalendarRange, Globe, Layers, Tags, Users } from 'lucide-vue-next'
|
||||
|
||||
import { useStatisticsSummary } from '../composables/useStatisticsSummary'
|
||||
|
||||
const { data, loading } = useStatisticsSummary()
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`
|
||||
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`
|
||||
return `${(bytes / 1024).toFixed(1)} KB`
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
return n.toLocaleString()
|
||||
}
|
||||
|
||||
const publicationRange = computed(() => {
|
||||
if (!data.value) return '-'
|
||||
const { publicationYearMin: min, publicationYearMax: max } = data.value
|
||||
if (!min && !max) return '-'
|
||||
if (min === max) return String(min)
|
||||
return `${min} - ${max}`
|
||||
})
|
||||
|
||||
const kpis = computed(() => [
|
||||
{ icon: BookOpen, label: 'Books', value: data.value ? formatNumber(data.value.totalBooks) : '-', colorIndex: 1 },
|
||||
{ icon: Users, label: 'Authors', value: data.value ? formatNumber(data.value.totalAuthors) : '-', colorIndex: 2 },
|
||||
{ icon: Layers, label: 'Series', value: data.value ? formatNumber(data.value.totalSeries) : '-', colorIndex: 3 },
|
||||
{ icon: Building2, label: 'Publishers', value: data.value ? formatNumber(data.value.totalPublishers) : '-', colorIndex: 4 },
|
||||
{ icon: BarChart3, label: 'Storage', value: data.value ? formatBytes(data.value.totalStorageBytes) : '-', colorIndex: 5 },
|
||||
{ icon: Tags, label: 'Genres', value: data.value ? formatNumber(data.value.totalGenres) : '-', colorIndex: 6 },
|
||||
{ icon: Globe, label: 'Languages', value: data.value ? formatNumber(data.value.totalLanguages) : '-', colorIndex: 7 },
|
||||
{ icon: CalendarRange, label: 'Published', value: publicationRange.value, colorIndex: 8 },
|
||||
{ icon: CalendarPlus, label: 'This Year', value: data.value ? formatNumber(data.value.booksAddedThisYear) : '-', colorIndex: 9 },
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative overflow-hidden rounded-xl border bg-card">
|
||||
<div class="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/8 to-transparent" />
|
||||
<BarChart3 class="pointer-events-none absolute -right-4 -top-2 opacity-[0.04]" :size="160" aria-hidden="true" />
|
||||
|
||||
<div class="relative p-4">
|
||||
<div class="flex gap-3 overflow-x-auto pb-0.5 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<div
|
||||
v-for="kpi in kpis"
|
||||
:key="kpi.label"
|
||||
:class="['border-border/60 bg-background/50 flex shrink-0 items-center gap-3 rounded-lg border px-4 py-2.5', loading ? 'opacity-60' : '']"
|
||||
>
|
||||
<div
|
||||
class="shrink-0 rounded-md p-1.5"
|
||||
:style="{
|
||||
backgroundColor: `color-mix(in oklch, var(--chart-icon-${kpi.colorIndex}) 15%, transparent)`,
|
||||
color: `var(--chart-icon-${kpi.colorIndex})`,
|
||||
}"
|
||||
>
|
||||
<component :is="kpi.icon" class="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-foreground text-base font-semibold leading-tight tabular-nums">{{ kpi.value }}</p>
|
||||
<p class="text-muted-foreground text-xs">{{ kpi.label }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { shallowRef, watchEffect } from 'vue'
|
||||
import VChart from 'vue-echarts'
|
||||
import { HardDrive } from 'lucide-vue-next'
|
||||
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
||||
|
||||
import { useStorageByFormat } from '../composables/useStorageByFormat'
|
||||
import ChartCard from './ChartCard.vue'
|
||||
|
||||
const { data, loading, error } = useStorageByFormat()
|
||||
const { md } = useBreakpoints(breakpointsTailwind)
|
||||
|
||||
const option = shallowRef({})
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`
|
||||
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`
|
||||
return `${(bytes / 1024).toFixed(1)} KB`
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (!data.value.items.length) return
|
||||
option.value = {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
confine: true,
|
||||
enterable: false,
|
||||
formatter: (params: { name: string; value: number; percent: number }) => `${params.name}: ${formatBytes(params.value)} (${params.percent}%)`,
|
||||
},
|
||||
legend: {
|
||||
orient: md.value ? 'vertical' : 'horizontal',
|
||||
right: md.value ? '2%' : 'auto',
|
||||
bottom: md.value ? 'auto' : 0,
|
||||
top: md.value ? 'middle' : 'auto',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
center: md.value ? ['38%', '50%'] : ['50%', '44%'],
|
||||
data: data.value.items.map((item) => ({ name: item.format.toUpperCase(), value: item.sizeBytes })),
|
||||
cursor: 'default',
|
||||
label: { show: false },
|
||||
emphasis: { disabled: true },
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ChartCard
|
||||
title="Storage by Format"
|
||||
:icon="HardDrive"
|
||||
:color-index="4"
|
||||
:loading
|
||||
:error
|
||||
:empty="!data.items.length"
|
||||
:unknown-count="data.unknownCount"
|
||||
>
|
||||
<VChart :option autoresize style="height: 100%" />
|
||||
</ChartCard>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { shallowRef, watchEffect } from 'vue'
|
||||
import VChart from 'vue-echarts'
|
||||
import { Users } from 'lucide-vue-next'
|
||||
|
||||
import { useTopAuthors } from '../composables/useTopAuthors'
|
||||
import ChartCard from './ChartCard.vue'
|
||||
|
||||
const { data, loading, error } = useTopAuthors()
|
||||
const option = shallowRef({})
|
||||
|
||||
watchEffect(() => {
|
||||
if (!data.value.items.length) return
|
||||
option.value = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
confine: true,
|
||||
enterable: false,
|
||||
formatter: (params: { name: string; value: number }[]) => {
|
||||
const p = params[0]
|
||||
if (!p) return ''
|
||||
return `${p.name}: <strong>${p.value}</strong> books`
|
||||
},
|
||||
},
|
||||
grid: { left: '3%', right: '3%', bottom: '28%', top: '8%', containLabel: false },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.value.items.map((d) => d.name),
|
||||
axisTick: { show: false },
|
||||
axisLabel: {
|
||||
fontSize: 11,
|
||||
rotate: 45,
|
||||
interval: 0,
|
||||
overflow: 'truncate',
|
||||
width: 80,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
minInterval: 1,
|
||||
axisLabel: { fontSize: 11 },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: data.value.items.map((d) => d.count),
|
||||
cursor: 'default',
|
||||
itemStyle: { borderRadius: [3, 3, 0, 0] },
|
||||
barMaxWidth: 32,
|
||||
emphasis: { disabled: true },
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ChartCard title="Top 25 Authors" :icon="Users" :color-index="6" :loading :error :empty="!data.items.length">
|
||||
<VChart :option autoresize style="height: 100%" />
|
||||
</ChartCard>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { BooksAddedDataPoint, StatisticsResult } from '@projectx/types'
|
||||
import { fetchBooksAddedOverTime } from '../api/statistics.api'
|
||||
import { useStatisticsConfig } from './useStatisticsConfig'
|
||||
|
||||
export function useBooksAddedOverTime() {
|
||||
const data = ref<StatisticsResult<BooksAddedDataPoint>>({ items: [], unknownCount: 0 })
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
|
||||
const { filters } = useStatisticsConfig()
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
data.value = await fetchBooksAddedOverTime(filters.value)
|
||||
} catch {
|
||||
error.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch([() => filters.value.libraryIds.join(','), () => filters.value.booksOverTimeGranularity, () => filters.value.booksOverTimeRange], load)
|
||||
onMounted(load)
|
||||
return { data, loading, error }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { FormatDistributionItem, StatisticsResult } from '@projectx/types'
|
||||
import { fetchFormatDistribution } from '../api/statistics.api'
|
||||
import { useStatisticsConfig } from './useStatisticsConfig'
|
||||
|
||||
export function useFormatDistribution() {
|
||||
const data = ref<StatisticsResult<FormatDistributionItem>>({ items: [], unknownCount: 0 })
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
|
||||
const { filters } = useStatisticsConfig()
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
data.value = await fetchFormatDistribution(filters.value)
|
||||
} catch {
|
||||
error.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => filters.value.libraryIds.join(','), load)
|
||||
onMounted(load)
|
||||
return { data, loading, error }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { GenreDistributionItem, StatisticsResult } from '@projectx/types'
|
||||
import { fetchGenreDistribution } from '../api/statistics.api'
|
||||
import { useStatisticsConfig } from './useStatisticsConfig'
|
||||
|
||||
export function useGenreDistribution() {
|
||||
const data = ref<StatisticsResult<GenreDistributionItem>>({ items: [], unknownCount: 0 })
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
|
||||
const { filters } = useStatisticsConfig()
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
data.value = await fetchGenreDistribution(filters.value)
|
||||
} catch {
|
||||
error.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => filters.value.libraryIds.join(','), load)
|
||||
onMounted(load)
|
||||
return { data, loading, error }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { LanguageDistributionItem, StatisticsResult } from '@projectx/types'
|
||||
import { fetchLanguageDistribution } from '../api/statistics.api'
|
||||
import { useStatisticsConfig } from './useStatisticsConfig'
|
||||
|
||||
export function useLanguageDistribution() {
|
||||
const data = ref<StatisticsResult<LanguageDistributionItem>>({ items: [], unknownCount: 0 })
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
|
||||
const { filters } = useStatisticsConfig()
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
data.value = await fetchLanguageDistribution(filters.value)
|
||||
} catch {
|
||||
error.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => filters.value.libraryIds.join(','), load)
|
||||
onMounted(load)
|
||||
return { data, loading, error }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { MetadataCompletenessItem, StatisticsResult } from '@projectx/types'
|
||||
import { fetchMetadataCompleteness } from '../api/statistics.api'
|
||||
import { useStatisticsConfig } from './useStatisticsConfig'
|
||||
|
||||
export function useMetadataCompleteness() {
|
||||
const data = ref<StatisticsResult<MetadataCompletenessItem>>({ items: [], unknownCount: 0 })
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
|
||||
const { filters } = useStatisticsConfig()
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
data.value = await fetchMetadataCompleteness(filters.value)
|
||||
} catch {
|
||||
error.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => filters.value.libraryIds.join(','), load)
|
||||
onMounted(load)
|
||||
return { data, loading, error }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { PublicationDecadeItem, StatisticsResult } from '@projectx/types'
|
||||
import { fetchPublicationDecade } from '../api/statistics.api'
|
||||
import { useStatisticsConfig } from './useStatisticsConfig'
|
||||
|
||||
export function usePublicationDecade() {
|
||||
const data = ref<StatisticsResult<PublicationDecadeItem>>({ items: [], unknownCount: 0 })
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
|
||||
const { filters } = useStatisticsConfig()
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
data.value = await fetchPublicationDecade(filters.value)
|
||||
} catch {
|
||||
error.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => filters.value.libraryIds.join(','), load)
|
||||
onMounted(load)
|
||||
return { data, loading, error }
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import type {
|
||||
ChartConfigEntry,
|
||||
StatisticsChartId,
|
||||
StatisticsDateRange,
|
||||
StatisticsFilterConfig,
|
||||
StatisticsGranularity,
|
||||
StatisticsSettings,
|
||||
} from '@projectx/types'
|
||||
import { api } from '@/lib/api'
|
||||
import { useAuth } from '@/features/auth/composables/useAuth'
|
||||
|
||||
// Must stay in sync with CHART_REGISTRY in StatisticsGrid.vue.
|
||||
const KNOWN_CHART_IDS: StatisticsChartId[] = [
|
||||
'format-distribution',
|
||||
'language-distribution',
|
||||
'books-added-over-time',
|
||||
'storage-by-format',
|
||||
'publication-decade',
|
||||
'top-authors',
|
||||
'metadata-completeness',
|
||||
'genre-distribution',
|
||||
]
|
||||
|
||||
const DEFAULT_FILTERS: StatisticsFilterConfig = {
|
||||
libraryIds: [],
|
||||
booksOverTimeGranularity: 'monthly',
|
||||
booksOverTimeRange: 'all-time',
|
||||
}
|
||||
|
||||
const config = ref<ChartConfigEntry[]>([])
|
||||
const filters = ref<StatisticsFilterConfig>({ ...DEFAULT_FILTERS })
|
||||
|
||||
function normalizeCharts(saved: ChartConfigEntry[] | undefined): ChartConfigEntry[] {
|
||||
const knownSet = new Set<StatisticsChartId>(KNOWN_CHART_IDS)
|
||||
const filtered = (saved ?? []).filter((e) => knownSet.has(e.id))
|
||||
const savedIds = new Set(filtered.map((e) => e.id))
|
||||
const newEntries: ChartConfigEntry[] = KNOWN_CHART_IDS.filter((id) => !savedIds.has(id)).map((id, i) => ({
|
||||
id,
|
||||
visible: true,
|
||||
order: filtered.length + i,
|
||||
}))
|
||||
return [...filtered, ...newEntries]
|
||||
}
|
||||
|
||||
function normalizeFilters(saved: StatisticsFilterConfig | undefined): StatisticsFilterConfig {
|
||||
return {
|
||||
libraryIds: saved?.libraryIds ?? DEFAULT_FILTERS.libraryIds,
|
||||
booksOverTimeGranularity: saved?.booksOverTimeGranularity ?? DEFAULT_FILTERS.booksOverTimeGranularity,
|
||||
booksOverTimeRange: saved?.booksOverTimeRange ?? DEFAULT_FILTERS.booksOverTimeRange,
|
||||
}
|
||||
}
|
||||
|
||||
async function persist() {
|
||||
const payload: StatisticsSettings = { charts: config.value, filters: filters.value }
|
||||
try {
|
||||
await api('/api/v1/users/me', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ settings: { statisticsConfig: payload } }),
|
||||
})
|
||||
} catch {
|
||||
toast.error('Failed to save chart configuration')
|
||||
}
|
||||
}
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function scheduleSave() {
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(persist, 600)
|
||||
}
|
||||
|
||||
export function useStatisticsConfig() {
|
||||
const { user } = useAuth()
|
||||
|
||||
function init() {
|
||||
const saved = user.value?.settings?.statisticsConfig
|
||||
config.value = normalizeCharts(saved?.charts)
|
||||
filters.value = normalizeFilters(saved?.filters)
|
||||
}
|
||||
|
||||
const orderedCharts = computed(() => [...config.value].sort((a, b) => a.order - b.order))
|
||||
const visibleCharts = computed(() => orderedCharts.value.filter((c) => c.visible))
|
||||
|
||||
function toggleVisibility(id: StatisticsChartId) {
|
||||
const entry = config.value.find((c) => c.id === id)
|
||||
if (!entry) return
|
||||
entry.visible = !entry.visible
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function reorder(newOrder: ChartConfigEntry[]) {
|
||||
config.value = newOrder.map((entry, i) => ({ ...entry, order: i }))
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function setLibraryFilter(ids: number[]) {
|
||||
filters.value = { ...filters.value, libraryIds: ids }
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function setGranularity(g: StatisticsGranularity) {
|
||||
filters.value = { ...filters.value, booksOverTimeGranularity: g }
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function setDateRange(r: StatisticsDateRange) {
|
||||
filters.value = { ...filters.value, booksOverTimeRange: r }
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function resetToDefaults() {
|
||||
config.value = KNOWN_CHART_IDS.map((id, i) => ({ id, visible: true, order: i }))
|
||||
filters.value = { ...DEFAULT_FILTERS }
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
return { orderedCharts, visibleCharts, filters, init, toggleVisibility, reorder, resetToDefaults, setLibraryFilter, setGranularity, setDateRange }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import type { StatisticsSummary } from '@projectx/types'
|
||||
|
||||
import { fetchStatisticsSummary } from '../api/statistics.api'
|
||||
import { useStatisticsConfig } from './useStatisticsConfig'
|
||||
|
||||
export function useStatisticsSummary() {
|
||||
const { filters } = useStatisticsConfig()
|
||||
const data = ref<StatisticsSummary | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
data.value = await fetchStatisticsSummary(filters.value)
|
||||
} catch {
|
||||
// leave stale data on error — header degrades gracefully with dashes
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => filters.value.libraryIds.join(','), load, { immediate: true })
|
||||
|
||||
return { data, loading }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { StorageByFormatItem, StatisticsResult } from '@projectx/types'
|
||||
import { fetchStorageByFormat } from '../api/statistics.api'
|
||||
import { useStatisticsConfig } from './useStatisticsConfig'
|
||||
|
||||
export function useStorageByFormat() {
|
||||
const data = ref<StatisticsResult<StorageByFormatItem>>({ items: [], unknownCount: 0 })
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
|
||||
const { filters } = useStatisticsConfig()
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
data.value = await fetchStorageByFormat(filters.value)
|
||||
} catch {
|
||||
error.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => filters.value.libraryIds.join(','), load)
|
||||
onMounted(load)
|
||||
return { data, loading, error }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { TopAuthorItem, StatisticsResult } from '@projectx/types'
|
||||
import { fetchTopAuthors } from '../api/statistics.api'
|
||||
import { useStatisticsConfig } from './useStatisticsConfig'
|
||||
|
||||
export function useTopAuthors() {
|
||||
const data = ref<StatisticsResult<TopAuthorItem>>({ items: [], unknownCount: 0 })
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
|
||||
const { filters } = useStatisticsConfig()
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
data.value = await fetchTopAuthors(filters.value)
|
||||
} catch {
|
||||
error.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => filters.value.libraryIds.join(','), load)
|
||||
onMounted(load)
|
||||
return { data, loading, error }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { use, registerTheme } from 'echarts/core'
|
||||
import { SVGRenderer } from 'echarts/renderers'
|
||||
import { BarChart, LineChart, PieChart } from 'echarts/charts'
|
||||
import { GridComponent, LegendComponent, TitleComponent, TooltipComponent } from 'echarts/components'
|
||||
|
||||
// SVG renderer: events fire on real DOM elements, not via canvas hit-test.
|
||||
// This eliminates the cursor-flicker / hover-disappear bug that canvas
|
||||
// hit-test coordinate mismatches cause with this layout.
|
||||
use([SVGRenderer, PieChart, BarChart, LineChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent])
|
||||
|
||||
function readCssColor(varName: string): string {
|
||||
const el = document.createElement('span')
|
||||
el.style.color = `var(${varName})`
|
||||
document.body.appendChild(el)
|
||||
const color = getComputedStyle(el).color
|
||||
el.remove()
|
||||
return color
|
||||
}
|
||||
|
||||
function buildTheme() {
|
||||
const border = readCssColor('--border')
|
||||
const mutedFg = readCssColor('--muted-foreground')
|
||||
const axisConfig = {
|
||||
axisLine: { show: true, lineStyle: { color: border } },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { show: true, color: mutedFg },
|
||||
splitLine: { show: true, lineStyle: { color: [border] } },
|
||||
splitArea: { show: false },
|
||||
}
|
||||
return {
|
||||
color: [
|
||||
readCssColor('--chart-1'),
|
||||
readCssColor('--chart-2'),
|
||||
readCssColor('--chart-3'),
|
||||
readCssColor('--chart-4'),
|
||||
readCssColor('--chart-5'),
|
||||
readCssColor('--chart-6'),
|
||||
readCssColor('--chart-7'),
|
||||
readCssColor('--chart-8'),
|
||||
readCssColor('--chart-9'),
|
||||
readCssColor('--chart-10'),
|
||||
],
|
||||
backgroundColor: 'transparent',
|
||||
legend: { textStyle: { color: readCssColor('--foreground') } },
|
||||
tooltip: {
|
||||
backgroundColor: readCssColor('--card'),
|
||||
borderColor: border,
|
||||
textStyle: { color: readCssColor('--card-foreground') },
|
||||
},
|
||||
categoryAxis: axisConfig,
|
||||
valueAxis: axisConfig,
|
||||
logAxis: axisConfig,
|
||||
timeAxis: axisConfig,
|
||||
}
|
||||
}
|
||||
|
||||
export function initChartThemes(version: number = 0): void {
|
||||
registerTheme(`projectx-v${version}`, buildTheme())
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import './assets/main.css'
|
||||
import './lib/echarts'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
@@ -116,6 +116,11 @@ const router = createRouter({
|
||||
name: 'staging',
|
||||
component: () => import('@/views/StagingView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/statistics',
|
||||
name: 'statistics',
|
||||
component: () => import('@/features/statistics/components/StatisticsPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/library/:id',
|
||||
name: 'library',
|
||||
|
||||
Reference in New Issue
Block a user