Files
Notizarr/apps/web/src/pages/admin/TopicsAdminPage.tsx
T
2026-09-07 09:54:30 +00:00

234 lines
9.0 KiB
TypeScript

import { Archive, BarChart3, Plus, Trash2, X } from 'lucide-react'
import { type FormEvent, useState } from 'react'
import { Link } from 'react-router-dom'
import { Checkbox } from '../../components/ui/Checkbox'
import { Switch } from '../../components/ui/Switch'
import { cn } from '../../lib/utils'
import { useCreateTopic, useDeleteTopic, useUpdateTopic } from '../../hooks/useAdmin'
import { useTopics } from '../../hooks/useTopics'
const RETENTION_OPTIONS = [
{ label: '7 days', value: 7 },
{ label: '30 days', value: 30 },
{ label: '90 days', value: 90 },
{ label: '365 days', value: 365 },
{ label: 'Forever', value: 0 },
]
export function TopicsAdminPage() {
const { data: topics } = useTopics()
const createTopic = useCreateTopic()
const updateTopic = useUpdateTopic()
const deleteTopic = useDeleteTopic()
const [slug, setSlug] = useState('')
const [displayName, setDisplayName] = useState('')
const [formError, setFormError] = useState<string | null>(null)
const [selected, setSelected] = useState<Set<string>>(new Set())
const handleCreate = (e: FormEvent) => {
e.preventDefault()
setFormError(null)
createTopic.mutate(
{ slug, displayName },
{
onSuccess: () => {
setSlug('')
setDisplayName('')
},
onError: (err) => setFormError(err instanceof Error ? err.message : 'Failed to create topic'),
},
)
}
const toggleSelected = (id: string) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const handleBulkRetention = (days: number) => {
selected.forEach((id) => updateTopic.mutate({ id, changes: { retentionDays: days } }))
setSelected(new Set())
}
const handleBulkArchive = () => {
if (!window.confirm(`Archive ${selected.size} topic${selected.size === 1 ? '' : 's'}?`)) return
selected.forEach((id) => updateTopic.mutate({ id, changes: { archived: true } }))
setSelected(new Set())
}
return (
<div className="space-y-6">
<form
onSubmit={handleCreate}
className="flex flex-wrap items-end gap-3 rounded-xl border border-base-border bg-base-surface p-4 shadow-card"
>
<div className="flex-1 basis-40 space-y-1">
<label className="text-xs font-medium text-slate-400">Slug</label>
<input
required
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="homeassistant"
className="w-full rounded-lg border border-base-border bg-base-raised px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:border-accent focus:outline-none"
/>
</div>
<div className="flex-1 basis-40 space-y-1">
<label className="text-xs font-medium text-slate-400">Display name</label>
<input
required
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Home Assistant"
className="w-full rounded-lg border border-base-border bg-base-raised px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:border-accent focus:outline-none"
/>
</div>
<button
type="submit"
disabled={createTopic.isPending}
className="flex items-center gap-1.5 rounded-lg bg-accent px-3.5 py-2 text-sm font-semibold text-white transition-colors hover:bg-accent-dim disabled:opacity-50"
>
<Plus className="h-4 w-4" />
Create topic
</button>
{formError && <p className="w-full text-xs text-red-400">{formError}</p>}
</form>
{selected.size > 0 && (
<div className="sticky top-0 z-10 flex flex-wrap items-center gap-3 rounded-xl border border-accent/30 bg-base-surface/95 px-4 py-3 shadow-card backdrop-blur">
<span className="flex items-center gap-2 text-sm font-medium text-slate-200">
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-accent px-1.5 text-xs font-bold text-white">
{selected.size}
</span>
selected
</span>
<div className="h-5 w-px bg-base-border" aria-hidden />
<label className="flex items-center gap-2 text-xs font-medium text-slate-400">
Retention
<select
defaultValue=""
onChange={(e) => e.target.value && handleBulkRetention(Number(e.target.value))}
className="rounded-lg border border-base-border bg-base-raised px-2 py-1.5 text-xs text-slate-200 focus:border-accent focus:outline-none"
>
<option value="" disabled>
Choose…
</option>
{RETENTION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
<button
type="button"
onClick={handleBulkArchive}
className="flex items-center gap-1.5 rounded-lg border border-base-border px-3 py-1.5 text-xs font-medium text-slate-300 transition-colors hover:bg-base-hover"
>
<Archive className="h-3.5 w-3.5" />
Archive
</button>
<button
type="button"
onClick={() => setSelected(new Set())}
aria-label="Clear selection"
className="ml-auto flex items-center gap-1.5 rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-base-hover hover:text-slate-200"
>
<X className="h-4 w-4" />
</button>
</div>
)}
<div className="space-y-2">
{topics?.map((topic) => {
const isSelected = selected.has(topic.id)
return (
<div
key={topic.id}
className={cn(
'flex flex-wrap items-center gap-3 rounded-xl border p-4 shadow-card transition-colors',
isSelected ? 'border-accent/40 bg-accent/5' : 'border-base-border bg-base-surface',
)}
>
<Checkbox
checked={isSelected}
onChange={() => toggleSelected(topic.id)}
label={`Select ${topic.displayName}`}
/>
<div className="min-w-32 flex-1">
<p className="text-sm font-medium text-slate-100">{topic.displayName}</p>
<p className="text-xs text-slate-500">
{topic.slug} · {topic.totalCount.toLocaleString()} total
</p>
</div>
<label className="flex items-center gap-2 text-xs text-slate-400">
Retention
<select
value={topic.retentionDays ?? 90}
onChange={(e) =>
updateTopic.mutate({
id: topic.id,
changes: { retentionDays: Number(e.target.value) },
})
}
className="rounded-lg border border-base-border bg-base-raised px-2 py-1.5 text-xs text-slate-200 focus:border-accent focus:outline-none"
>
{RETENTION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2 text-xs text-slate-400">
Restricted
<Switch
checked={topic.restricted}
onChange={(restricted) => updateTopic.mutate({ id: topic.id, changes: { restricted } })}
label={`Restrict ${topic.displayName}`}
/>
</label>
<Link
to={`/admin/topics/${topic.slug}/stats`}
className="flex items-center gap-1.5 rounded-lg border border-base-border px-3 py-1.5 text-xs font-medium text-slate-400 transition-colors hover:bg-base-hover hover:text-slate-200"
>
<BarChart3 className="h-3.5 w-3.5" />
Stats
</Link>
<button
type="button"
disabled={deleteTopic.isPending}
onClick={() => {
if (
window.confirm(
`Delete "${topic.displayName}" and all ${topic.totalCount.toLocaleString()} of its notifications? This can't be undone.`,
)
) {
deleteTopic.mutate(topic.id)
}
}}
className="flex items-center gap-1.5 rounded-lg border border-base-border px-3 py-1.5 text-xs font-medium text-slate-400 transition-colors hover:bg-red-500/10 hover:text-red-400"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</button>
</div>
)
})}
</div>
</div>
)
}