234 lines
9.0 KiB
TypeScript
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>
|
|
)
|
|
}
|