nsfwapp/frontend/src/components/ui/FinishedReportModal.tsx
2026-06-09 14:24:50 +02:00

846 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// frontend\src\components\ui\FinishedReportModal.tsx
'use client'
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import {
ArchiveBoxIcon,
ArrowPathIcon,
CalendarDaysIcon,
ChartBarIcon,
ClockIcon,
FireIcon,
StarIcon,
} from '@heroicons/react/24/outline'
import type {
FinishedReportDailyItem,
FinishedReportMode,
FinishedReportStats,
FinishedReportTopItem,
RecordJob,
} from '../../types'
import Modal from './Modal'
import Button from './Button'
import ButtonGroup from './ButtonGroup'
import LoadingSpinner from './LoadingSpinner'
import { autoTagsForJob, ratingStarsForJob } from './autoTags'
import { SegmentLabelIcon } from './Icons'
const REPORT_PAGE_SIZE = 1000
const nf = new Intl.NumberFormat('de-DE')
function fmtInt(value: number | undefined | null) {
if (value == null || !Number.isFinite(value)) return '—'
return nf.format(value)
}
function fmtBytes(value: number | undefined | null) {
if (value == null || !Number.isFinite(value)) return '—'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let v = value
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
const digits = i <= 1 ? 0 : 1
return `${v.toFixed(digits)} ${units[i]}`
}
function fmtDuration(totalSeconds: number | undefined | null) {
if (totalSeconds == null || !Number.isFinite(totalSeconds) || totalSeconds <= 0) {
return '—'
}
const s = Math.floor(totalSeconds)
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const sec = s % 60
if (h > 0) {
return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
}
return `${m}:${String(sec).padStart(2, '0')}`
}
function parseMeta(metaRaw: unknown): Record<string, any> | null {
if (!metaRaw) return null
if (typeof metaRaw === 'string') {
try {
const parsed = JSON.parse(metaRaw)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, any>
: null
} catch {
return null
}
}
return typeof metaRaw === 'object' && !Array.isArray(metaRaw)
? metaRaw as Record<string, any>
: null
}
function readNumberLike(...values: unknown[]): number | null {
for (const value of values) {
const n = Number(value)
if (Number.isFinite(n) && n > 0) return n
}
return null
}
function readJobDurationSeconds(job: RecordJob): number | null {
const meta = parseMeta((job as any)?.meta)
return readNumberLike(
(job as any)?.durationSeconds,
(job as any)?.durationSec,
meta?.media?.durationSeconds,
meta?.media?.durationSec,
meta?.file?.durationSeconds,
meta?.file?.durationSec,
meta?.durationSeconds,
meta?.durationSec
)
}
function readJobSizeBytes(job: RecordJob): number | null {
const meta = parseMeta((job as any)?.meta)
return readNumberLike(
(job as any)?.sizeBytes,
(job as any)?.fileSizeBytes,
(job as any)?.bytes,
(job as any)?.size,
meta?.file?.sizeBytes,
meta?.file?.bytes,
meta?.sizeBytes,
meta?.bytes,
meta?.size
)
}
function baseName(path: string) {
return String(path || '').split(/[\\/]/).pop() || ''
}
function stripHotPrefix(name: string) {
return String(name || '').startsWith('HOT ')
? String(name || '').slice(4)
: String(name || '')
}
function isHotOutput(output: string) {
return baseName(output).startsWith('HOT ')
}
function isKeepOutput(output: string) {
return String(output || '').replaceAll('\\', '/').includes('/keep/')
}
function modelNameFromOutput(output?: string) {
const fileRaw = baseName(output || '')
const file = stripHotPrefix(fileRaw)
if (!file) return '—'
const stem = file.replace(/\.[^.]+$/, '')
const m = stem.match(/^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}$/)
if (m?.[1]) return m[1]
const i = stem.lastIndexOf('_')
return i > 0 ? stem.slice(0, i) : stem
}
function readJobDate(job: RecordJob): Date | null {
const raw =
(job as any)?.endedAt ??
(job as any)?.completedAt ??
(job as any)?.startedAt ??
null
if (!raw) return null
const d = new Date(raw)
return Number.isFinite(d.getTime()) ? d : null
}
function startOfDay(value: Date) {
const d = new Date(value)
d.setHours(0, 0, 0, 0)
return d
}
function addDays(value: Date, days: number) {
const d = new Date(value)
d.setDate(d.getDate() + days)
return d
}
function startOfWeekMonday(value: Date) {
const d = startOfDay(value)
const day = d.getDay()
const diff = day === 0 ? -6 : 1 - day
d.setDate(d.getDate() + diff)
return d
}
function reportPeriod(mode: FinishedReportMode, now = new Date()) {
if (mode === 'week') {
const start = startOfWeekMonday(now)
return {
start,
end: addDays(start, 7),
}
}
const start = startOfDay(now)
return {
start,
end: addDays(start, 1),
}
}
function dateKey(value: Date) {
return [
value.getFullYear(),
String(value.getMonth() + 1).padStart(2, '0'),
String(value.getDate()).padStart(2, '0'),
].join('-')
}
function shortDateLabel(value: Date) {
return value.toLocaleDateString('de-DE', {
weekday: 'short',
day: '2-digit',
month: '2-digit',
})
}
function longDateLabel(value: Date) {
return value.toLocaleDateString('de-DE', {
weekday: 'long',
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
function rangeLabel(start: Date, end: Date) {
const last = addDays(end, -1)
if (dateKey(start) === dateKey(last)) {
return longDateLabel(start)
}
return `${start.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})} ${last.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})}`
}
function incrementMap(map: Map<string, FinishedReportTopItem>, label: string, iconLabel?: string) {
const clean = String(label || '').trim()
if (!clean) return
const key = clean.toLowerCase()
const current = map.get(key)
if (current) {
current.count += 1
return
}
map.set(key, {
label: clean,
iconLabel,
count: 1,
})
}
function topItems(map: Map<string, FinishedReportTopItem>, limit = 8) {
return Array.from(map.values())
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, 'de'))
.slice(0, limit)
}
function buildReportStats(rows: RecordJob[], mode: FinishedReportMode): FinishedReportStats {
const { start, end } = reportPeriod(mode)
const modelCounts = new Map<string, FinishedReportTopItem>()
const tagCounts = new Map<string, FinishedReportTopItem>()
const dailyMap = new Map<string, FinishedReportDailyItem>()
for (let d = new Date(start); d < end; d = addDays(d, 1)) {
const key = dateKey(d)
dailyMap.set(key, {
key,
label: shortDateLabel(d),
count: 0,
durationSeconds: 0,
sizeBytes: 0,
})
}
const ratingCounts: Record<1 | 2 | 3 | 4 | 5, number> = {
1: 0,
2: 0,
3: 0,
4: 0,
5: 0,
}
let totalCount = 0
let totalDurationSeconds = 0
let totalSizeBytes = 0
let knownSizeCount = 0
let hotCount = 0
let keepCount = 0
let ratedCount = 0
for (const job of rows) {
const d = readJobDate(job)
if (!d || d < start || d >= end) continue
totalCount++
const output = String((job as any)?.output ?? '')
if (isHotOutput(output)) hotCount++
if (isKeepOutput(output)) keepCount++
const duration = readJobDurationSeconds(job)
if (duration != null) {
totalDurationSeconds += duration
}
const sizeBytes = readJobSizeBytes(job)
if (sizeBytes != null) {
totalSizeBytes += sizeBytes
knownSizeCount++
}
const bucket = dailyMap.get(dateKey(d))
if (bucket) {
bucket.count += 1
bucket.durationSeconds += duration ?? 0
bucket.sizeBytes += sizeBytes ?? 0
}
const model = modelNameFromOutput(output)
if (model && model !== '—') {
incrementMap(modelCounts, model)
}
for (const tag of autoTagsForJob(job)) {
incrementMap(tagCounts, tag, tag)
}
const stars = ratingStarsForJob(job)
if (stars != null) {
ratedCount++
ratingCounts[stars as 1 | 2 | 3 | 4 | 5] += 1
}
}
return {
mode,
start,
end,
totalCount,
totalDurationSeconds,
totalSizeBytes,
knownSizeCount,
hotCount,
keepCount,
ratedCount,
ratingCounts,
topModels: topItems(modelCounts, 8),
topTags: topItems(tagCounts, 10),
daily: Array.from(dailyMap.values()),
}
}
function ReportMetric({
label,
value,
sub,
icon,
tone = 'slate',
}: {
label: string
value: ReactNode
sub?: ReactNode
icon: ReactNode
tone?: 'slate' | 'sky' | 'amber' | 'emerald' | 'rose'
}) {
const toneCls =
tone === 'sky'
? 'border-sky-200 bg-sky-50/80 text-sky-700 dark:border-sky-400/20 dark:bg-sky-400/10 dark:text-sky-200'
: tone === 'amber'
? 'border-amber-200 bg-amber-50/80 text-amber-700 dark:border-amber-400/20 dark:bg-amber-400/10 dark:text-amber-200'
: tone === 'emerald'
? 'border-emerald-200 bg-emerald-50/80 text-emerald-700 dark:border-emerald-400/20 dark:bg-emerald-400/10 dark:text-emerald-200'
: tone === 'rose'
? 'border-rose-200 bg-rose-50/80 text-rose-700 dark:border-rose-400/20 dark:bg-rose-400/10 dark:text-rose-200'
: 'border-gray-200 bg-gray-50/90 text-gray-600 dark:border-white/10 dark:bg-white/5 dark:text-gray-300'
return (
<div className={['rounded-2xl border p-3 shadow-sm', toneCls].join(' ')}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-[10px] font-black uppercase tracking-wide opacity-80">
{label}
</div>
<div className="mt-1 min-w-0 text-2xl font-black leading-tight text-gray-950 dark:text-white">
{value}
</div>
{sub ? (
<div className="mt-1 truncate text-[11px] font-medium text-gray-500 dark:text-gray-400">
{sub}
</div>
) : null}
</div>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-white/70 ring-1 ring-black/5 dark:bg-white/10 dark:ring-white/10">
{icon}
</div>
</div>
</div>
)
}
function ReportBarList({
title,
items,
empty,
icons = false,
}: {
title: string
items: FinishedReportTopItem[]
empty: string
icons?: boolean
}) {
const max = Math.max(1, items[0]?.count ?? 1)
const total = items.reduce((sum, item) => sum + item.count, 0)
return (
<section className="overflow-hidden rounded-2xl border border-gray-200/70 bg-white/80 shadow-sm dark:border-white/10 dark:bg-white/[0.04]">
<div className="flex items-center justify-between gap-3 border-b border-gray-200/70 bg-gray-50/80 px-3 py-2.5 dark:border-white/10 dark:bg-white/[0.03]">
<div>
<div className="text-sm font-bold text-gray-900 dark:text-white">
{title}
</div>
<div className="text-[11px] text-gray-500 dark:text-gray-400">
{items.length > 0 ? `${fmtInt(total)} Treffer gesamt` : 'Keine Daten'}
</div>
</div>
<span className="rounded-full bg-white px-2 py-0.5 text-[11px] font-bold text-gray-600 ring-1 ring-gray-200 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10">
Top {fmtInt(items.length)}
</span>
</div>
<div className="p-3">
{items.length === 0 ? (
<div className="rounded-xl bg-gray-50 p-3 text-sm text-gray-500 ring-1 ring-gray-200 dark:bg-white/5 dark:text-gray-400 dark:ring-white/10">
{empty}
</div>
) : (
<div className="space-y-2">
{items.map((item, index) => {
const width = `${Math.max(8, Math.round((item.count / max) * 100))}%`
const share = total > 0 ? Math.round((item.count / total) * 100) : 0
return (
<div
key={item.label}
className="rounded-2xl bg-gray-50 p-2.5 ring-1 ring-black/5 transition hover:bg-gray-100/80 dark:bg-white/5 dark:ring-white/10 dark:hover:bg-white/10"
>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2.5">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-white text-[11px] font-black text-gray-500 ring-1 ring-gray-200 dark:bg-gray-950/40 dark:text-gray-400 dark:ring-white/10">
#{index + 1}
</span>
{icons ? (
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-indigo-50 text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/15 dark:text-indigo-100 dark:ring-indigo-300/30">
<SegmentLabelIcon
label={item.iconLabel ?? item.label}
className="h-4.5 w-4.5"
/>
</span>
) : null}
<div className="min-w-0">
<div className="truncate text-sm font-bold text-gray-900 dark:text-white">
{item.label}
</div>
<div className="text-[11px] text-gray-500 dark:text-gray-400">
{share}% Anteil
</div>
</div>
</div>
<span className="shrink-0 rounded-full bg-indigo-50 px-2 py-0.5 text-[11px] font-black text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/15 dark:text-indigo-100 dark:ring-indigo-300/30">
{fmtInt(item.count)}
</span>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
<div
className="h-full rounded-full bg-indigo-500 dark:bg-indigo-400"
style={{ width }}
/>
</div>
</div>
)
})}
</div>
)}
</div>
</section>
)
}
function DailyBars({ items }: { items: FinishedReportDailyItem[] }) {
const max = Math.max(1, ...items.map((item) => item.count))
const total = items.reduce((sum, item) => sum + item.count, 0)
return (
<section className="overflow-hidden rounded-2xl border border-gray-200/70 bg-white/80 shadow-sm dark:border-white/10 dark:bg-white/[0.04]">
<div className="flex items-center justify-between gap-3 border-b border-gray-200/70 bg-gray-50/80 px-3 py-2.5 dark:border-white/10 dark:bg-white/[0.03]">
<div>
<div className="text-sm font-bold text-gray-900 dark:text-white">
Verlauf
</div>
<div className="text-[11px] text-gray-500 dark:text-gray-400">
Downloads pro Tag
</div>
</div>
<ChartBarIcon className="h-5 w-5 text-sky-500 dark:text-sky-300" />
</div>
<div className="p-3">
<div className="space-y-2.5">
{items.map((item) => {
const width = `${Math.max(item.count > 0 ? 8 : 0, Math.round((item.count / max) * 100))}%`
const share = total > 0 ? Math.round((item.count / total) * 100) : 0
return (
<div key={item.key}>
<div className="mb-1 flex items-center justify-between gap-3 text-xs">
<span className="font-semibold text-gray-700 dark:text-gray-200">
{item.label}
</span>
<span className="text-gray-500 dark:text-gray-400">
{fmtInt(item.count)} · {fmtDuration(item.durationSeconds)} · {share}%
</span>
</div>
<div className="h-2.5 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
<div
className="h-full rounded-full bg-sky-500 dark:bg-sky-400"
style={{ width }}
/>
</div>
</div>
)
})}
</div>
</div>
</section>
)
}
function RatingDistribution({ stats }: { stats: FinishedReportStats }) {
const max = Math.max(1, ...([1, 2, 3, 4, 5] as const).map((n) => stats.ratingCounts[n]))
return (
<section className="overflow-hidden rounded-2xl border border-gray-200/70 bg-white/80 shadow-sm dark:border-white/10 dark:bg-white/[0.04]">
<div className="flex items-center justify-between gap-3 border-b border-gray-200/70 bg-gray-50/80 px-3 py-2.5 dark:border-white/10 dark:bg-white/[0.03]">
<div>
<div className="text-sm font-bold text-gray-900 dark:text-white">
Bewertung
</div>
<div className="text-[11px] text-gray-500 dark:text-gray-400">
{fmtInt(stats.ratedCount)} bewertete Downloads
</div>
</div>
<StarIcon className="h-5 w-5 text-amber-500 dark:text-amber-300" />
</div>
<div className="p-3">
<div className="space-y-2.5">
{([5, 4, 3, 2, 1] as const).map((stars) => {
const count = stats.ratingCounts[stars]
const width = `${Math.max(count > 0 ? 8 : 0, Math.round((count / max) * 100))}%`
return (
<div key={stars}>
<div className="mb-1 flex items-center justify-between gap-3 text-xs">
<span className="font-semibold text-gray-700 dark:text-gray-200">
{'★'.repeat(stars)}
<span className="ml-1 text-gray-400">
{'★'.repeat(5 - stars)}
</span>
</span>
<span className="text-gray-500 dark:text-gray-400">
{fmtInt(count)}
</span>
</div>
<div className="h-2.5 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
<div
className="h-full rounded-full bg-amber-400 dark:bg-amber-300"
style={{ width }}
/>
</div>
</div>
)
})}
</div>
</div>
</section>
)
}
export default function FinishedReportModal({
open,
onClose,
}: {
open: boolean
onClose: () => void
}) {
const [mode, setMode] = useState<FinishedReportMode>('day')
const [rows, setRows] = useState<RecordJob[]>([])
const [availableCount, setAvailableCount] = useState(0)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const loadReportRows = useCallback(async () => {
setLoading(true)
setError('')
try {
const url =
`/api/record/done?page=1&pageSize=${REPORT_PAGE_SIZE}` +
`&sort=completed_desc&includeKeep=1&withCount=1`
const r = await fetch(url, { cache: 'no-store' })
const data = await r.json().catch(() => null)
if (!r.ok) {
throw new Error(String(data?.error || data?.message || `HTTP ${r.status}`))
}
const items = Array.isArray(data?.items) ? data.items as RecordJob[] : []
const count = Number(data?.count ?? items.length)
setRows(items)
setAvailableCount(Number.isFinite(count) ? count : items.length)
} catch (err: any) {
setError(err?.message || 'Report konnte nicht geladen werden.')
setRows([])
setAvailableCount(0)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (!open) return
void loadReportRows()
}, [open, loadReportRows])
const stats = useMemo(() => {
return buildReportStats(rows, mode)
}, [rows, mode])
const partialHint =
availableCount > rows.length
? `Es werden die neuesten ${fmtInt(rows.length)} von ${fmtInt(availableCount)} Downloads berücksichtigt.`
: `${fmtInt(rows.length)} Downloads geladen.`
const avgDuration =
stats.totalCount > 0 ? stats.totalDurationSeconds / stats.totalCount : 0
const avgSize =
stats.knownSizeCount > 0 ? stats.totalSizeBytes / stats.knownSizeCount : 0
const reportModeSwitch = (
<ButtonGroup
value={mode}
onChange={(id) => setMode(id as FinishedReportMode)}
size="md"
ariaLabel="Report-Zeitraum"
items={[
{
id: 'day',
label: 'Heute',
icon: <CalendarDaysIcon className="size-4" />,
},
{
id: 'week',
label: 'Diese Woche',
icon: <ChartBarIcon className="size-4" />,
},
]}
/>
)
const refreshButton = (
<Button
type="button"
size="sm"
rounded="md"
variant="secondary"
color="indigo"
onClick={() => void loadReportRows()}
isLoading={loading}
leadingIcon={<ArrowPathIcon className="h-4 w-4" />}
>
Aktualisieren
</Button>
)
return (
<Modal
open={open}
onClose={onClose}
title="Tages-/Wochenreport"
width="max-w-6xl"
scroll="body"
rightClassName="bg-gray-50/60 dark:bg-gray-950/20"
titleRight={
<div className="flex items-center gap-2">
{reportModeSwitch}
{refreshButton}
</div>
}
>
<div className="space-y-4 p-4 sm:p-5">
<div className="flex flex-wrap items-center justify-between gap-2 sm:hidden">
{reportModeSwitch}
{refreshButton}
</div>
<section className="overflow-hidden rounded-3xl border border-gray-200/70 bg-gradient-to-br from-white via-sky-50/70 to-indigo-50/70 shadow-sm dark:border-white/10 dark:from-white/[0.06] dark:via-sky-400/10 dark:to-indigo-400/10">
<div className="p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="inline-flex items-center gap-2 rounded-full bg-white/80 px-2.5 py-1 text-[11px] font-bold text-indigo-700 ring-1 ring-indigo-200 dark:bg-white/10 dark:text-indigo-100 dark:ring-indigo-300/30">
<CalendarDaysIcon className="h-4 w-4" />
{mode === 'day' ? 'Tagesreport' : 'Wochenreport'}
</div>
<h2 className="mt-3 text-2xl font-black tracking-tight text-gray-950 dark:text-white">
{mode === 'day' ? 'Heute' : 'Diese Woche'}
</h2>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-300">
{rangeLabel(stats.start, stats.end)}
</div>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
{loading ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/80 px-2.5 py-1 text-[11px] font-medium text-gray-500 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10">
<LoadingSpinner size="sm" />
Lädt
</span>
) : (
<span className="rounded-full bg-white/80 px-2.5 py-1 text-[11px] font-medium text-gray-600 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10">
{partialHint}
</span>
)}
<span className="rounded-full bg-white/80 px-2.5 py-1 text-[11px] font-medium text-gray-600 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10">
Ø Dauer: {fmtDuration(avgDuration)}
</span>
<span className="rounded-full bg-white/80 px-2.5 py-1 text-[11px] font-medium text-gray-600 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10">
Ø Größe: {avgSize > 0 ? fmtBytes(avgSize) : '—'}
</span>
</div>
{error ? (
<div className="mt-4 rounded-xl bg-red-50 p-3 text-sm text-red-700 ring-1 ring-red-200 dark:bg-red-500/10 dark:text-red-200 dark:ring-red-400/25">
{error}
</div>
) : null}
<div className="mt-4 grid grid-cols-2 gap-3 xl:grid-cols-4">
<ReportMetric
label="Downloads"
value={fmtInt(stats.totalCount)}
sub="fertiggestellt"
icon={<CalendarDaysIcon className="h-5 w-5" />}
tone="sky"
/>
<ReportMetric
label="Laufzeit"
value={fmtDuration(stats.totalDurationSeconds)}
sub="Videodauer"
icon={<ClockIcon className="h-5 w-5" />}
/>
<ReportMetric
label="Speicher"
value={stats.knownSizeCount > 0 ? fmtBytes(stats.totalSizeBytes) : '—'}
sub={stats.knownSizeCount > 0 ? `${fmtInt(stats.knownSizeCount)} Dateien` : 'keine Größen'}
icon={<ArchiveBoxIcon className="h-5 w-5" />}
tone="emerald"
/>
<ReportMetric
label="Markiert"
value={`${fmtInt(stats.hotCount)} / ${fmtInt(stats.keepCount)}`}
sub="HOT / Keep"
icon={<FireIcon className="h-5 w-5" />}
tone="amber"
/>
</div>
</div>
</section>
<div className="grid gap-4 xl:grid-cols-[0.9fr_1.1fr]">
<DailyBars items={stats.daily} />
<RatingDistribution stats={stats} />
</div>
<div className="grid gap-4 xl:grid-cols-2">
<ReportBarList
title="Top Models"
items={stats.topModels}
empty="In diesem Zeitraum wurden keine Models gefunden."
/>
<ReportBarList
title="Top Inhalte"
items={stats.topTags}
empty="In diesem Zeitraum wurden keine Auto-Tags gefunden."
icons
/>
</div>
</div>
</Modal>
)
}