Initial commit
This commit is contained in:
commit
38c24fc9dc
166 files changed
+22002
No files matched your search
@@ -0,0 +1,233 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user