Initial commit

This commit is contained in:
siliconsolder committed 2026-09-07 09:54:30 +00:00
commit 38c24fc9dc
166 files changed
+22002

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0a0b0e" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Notizarr" />
<link rel="icon" type="image/svg+xml" href="/icons/favicon.svg" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<title>Notizarr</title>
</head>
<body class="bg-base-bg text-slate-100 antialiased overflow-x-hidden">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
{
"name": "@notizarr/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite build --watch",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.sw.json --noEmit",
"test": "echo \"no tests yet\" && exit 0",
"generate-icons": "node scripts/generate-icons.mjs"
},
"dependencies": {
"@tanstack/react-query": "^5.59.0",
"lucide-react": "^0.451.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.18.3"
},
"devDependencies": {
"@types/react": "^18.3.9",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"sharp": "^0.35.0",
"tailwindcss": "^3.4.13",
"typescript": "^5.6.2",
"vite": "^5.4.8",
"vite-plugin-pwa": "^0.20.5",
"workbox-precaching": "^7.1.0",
"workbox-routing": "^7.1.0",
"workbox-strategies": "^7.1.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
View File
Whitespace-only changes.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

+17
View File
@@ -0,0 +1,17 @@
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#6366f1" />
<stop offset="1" stop-color="#4338ca" />
</linearGradient>
</defs>
<rect width="512" height="512" rx="112" fill="url(#bg)" />
<path
d="M176 336 L176 176 L336 336 L336 176"
fill="none"
stroke="#ffffff"
stroke-width="44"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 554 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+42
View File
@@ -0,0 +1,42 @@
// One-off generator, not part of the app's runtime or build. Run manually
// whenever the icon design changes:
// pnpm --filter @notizarr/web exec node scripts/generate-icons.mjs
// Requires `sharp` (devDependency) — rasterizes the hand-written SVG
// sources into the PNG sizes PWA manifests / iOS actually need. Output is
// committed to public/icons/, so this script does not need to run in CI or
// in the Docker build.
import { mkdir, readFile, writeFile, copyFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import sharp from 'sharp'
const scriptsDir = fileURLToPath(new URL('.', import.meta.url))
const outDir = path.resolve(scriptsDir, '../public/icons')
async function render(svgPath, outPath, size) {
const svg = await readFile(svgPath)
await sharp(svg).resize(size, size).png().toFile(outPath)
console.log(`wrote ${path.relative(scriptsDir, outPath)} (${size}x${size})`)
}
async function main() {
await mkdir(outDir, { recursive: true })
const standard = path.join(scriptsDir, 'icon-standard.svg')
const maskable = path.join(scriptsDir, 'icon-maskable.svg')
await render(standard, path.join(outDir, 'icon-192.png'), 192)
await render(standard, path.join(outDir, 'icon-512.png'), 512)
await render(maskable, path.join(outDir, 'maskable-512.png'), 512)
await render(maskable, path.join(outDir, 'apple-touch-icon.png'), 180)
await copyFile(standard, path.join(outDir, 'favicon.svg'))
console.log('wrote favicon.svg')
await writeFile(path.join(outDir, '.gitkeep'), '')
}
main().catch((err) => {
console.error(err)
process.exit(1)
})
+20
View File
@@ -0,0 +1,20 @@
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#6366f1" />
<stop offset="1" stop-color="#4338ca" />
</linearGradient>
</defs>
<!-- Full-bleed, unrounded background: the OS applies its own mask shape.
Glyph is sized to stay within the ~80% "safe zone" circle so it
survives circular/squircle/rounded-square masking on any platform. -->
<rect width="512" height="512" fill="url(#bg)" />
<path
d="M206 316 L206 196 L306 316 L306 196"
fill="none"
stroke="#ffffff"
stroke-width="30"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 770 B

+17
View File
@@ -0,0 +1,17 @@
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#6366f1" />
<stop offset="1" stop-color="#4338ca" />
</linearGradient>
</defs>
<rect width="512" height="512" rx="112" fill="url(#bg)" />
<path
d="M176 336 L176 176 L336 336 L336 176"
fill="none"
stroke="#ffffff"
stroke-width="44"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 554 B

+97
View File
@@ -0,0 +1,97 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import { AppShell } from './components/layout/AppShell'
import { useSession } from './hooks/useSession'
import { AdminLayout } from './pages/admin/AdminLayout'
import { AuditLogPage } from './pages/admin/AuditLogPage'
import { DashboardPage } from './pages/admin/DashboardPage'
import { DevicesAdminPage } from './pages/admin/DevicesAdminPage'
import { SourcesAdminPage } from './pages/admin/SourcesAdminPage'
import { AuthSettingsAdminPage } from './pages/admin/AuthSettingsAdminPage'
import { SystemSettingsAdminPage } from './pages/admin/SystemSettingsAdminPage'
import { BackupsAdminPage } from './pages/admin/BackupsAdminPage'
import { TopicStatsAdminPage } from './pages/admin/TopicStatsAdminPage'
import { TokensAdminPage } from './pages/admin/TokensAdminPage'
import { TopicsAdminPage } from './pages/admin/TopicsAdminPage'
import { UsersAdminPage } from './pages/admin/UsersAdminPage'
import { DiagnosticsPage } from './pages/DiagnosticsPage'
import { InboxPage } from './pages/InboxPage'
import { NotificationDetailPage } from './pages/NotificationDetailPage'
import { SearchPage } from './pages/SearchPage'
import { SettingsPage } from './pages/SettingsPage'
import { StarredPage } from './pages/StarredPage'
import { TopicDetailPage } from './pages/TopicDetailPage'
import { TopicsPage } from './pages/TopicsPage'
function SignInScreen() {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-base-bg px-6 text-center text-slate-100">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-accent/15 text-accent-bright">
<span className="text-lg font-bold">N</span>
</div>
<div>
<h1 className="text-lg font-semibold tracking-tight">Notizarr</h1>
<p className="mt-1 text-sm text-slate-500">Sign in to see your notifications.</p>
</div>
<a
href="/api/v1/auth/oidc/login"
className="rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-accent-dim"
>
Sign in
</a>
</div>
)
}
export default function App() {
const { data: session, isLoading } = useSession()
if (isLoading) {
return <div className="min-h-screen bg-base-bg" />
}
if (!session?.user) {
if (session?.authMode === 'oidc') return <SignInScreen />
// proxy mode with no trusted headers reaching us, or a config problem —
// there's nothing a client-side redirect can do about either.
return (
<div className="flex min-h-screen items-center justify-center bg-base-bg px-6 text-center text-sm text-slate-500">
Not authenticated. Check AUTH_MODE / trusted proxy configuration.
</div>
)
}
const isAdmin = session.user.role === 'admin'
return (
<Routes>
<Route element={<AppShell />}>
<Route index element={<InboxPage />} />
<Route path="topics" element={<TopicsPage />} />
<Route path="topics/:slug" element={<TopicDetailPage />} />
<Route path="topics/:slug/messages/:id" element={<NotificationDetailPage />} />
<Route path="search" element={<SearchPage />} />
<Route path="starred" element={<StarredPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="settings/diagnostics" element={<DiagnosticsPage />} />
{isAdmin && (
<Route path="admin" element={<AdminLayout />}>
<Route index element={<DashboardPage />} />
<Route path="topics" element={<TopicsAdminPage />} />
<Route path="topics/:slug/stats" element={<TopicStatsAdminPage />} />
<Route path="tokens" element={<TokensAdminPage />} />
<Route path="users" element={<UsersAdminPage />} />
<Route path="devices" element={<DevicesAdminPage />} />
<Route path="sources" element={<SourcesAdminPage />} />
<Route path="auth" element={<AuthSettingsAdminPage />} />
<Route path="system" element={<SystemSettingsAdminPage />} />
<Route path="backups" element={<BackupsAdminPage />} />
<Route path="audit-log" element={<AuditLogPage />} />
</Route>
)}
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
)
}
@@ -0,0 +1,27 @@
// Hand-rolled instead of pulling in a charting library — this is one bar
// chart on one admin page, and the design brief calls for graphs to be
// "tasteful and minimal", not feature-rich.
export function DailyBarChart({ data }: { data: { day: string; count: number }[] }) {
const max = Math.max(1, ...data.map((d) => d.count))
return (
<div className="flex h-32 items-end gap-1">
{data.map((d) => {
const heightPct = (d.count / max) * 100
const date = new Date(`${d.day}T00:00:00`)
const label = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
return (
<div key={d.day} className="group relative flex h-full flex-1 items-end">
<div
className="w-full rounded-t bg-accent/70 transition-colors group-hover:bg-accent"
style={{ height: `${Math.max(heightPct, d.count > 0 ? 4 : 1)}%` }}
/>
<div className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-1.5 -translate-x-1/2 whitespace-nowrap rounded-md bg-base-raised px-2 py-1 text-[11px] text-slate-200 opacity-0 shadow-raised transition-opacity group-hover:opacity-100">
{label}: {d.count}
</div>
</div>
)
})}
</div>
)
}
@@ -0,0 +1,49 @@
// CSS conic-gradient donut — same "hand-rolled, no charting library" call
// as DailyBarChart. A dedicated color per slice comes from the caller so
// it stays consistent with wherever else that entity is colored (e.g.
// colorForLabel for sources).
export interface DonutSlice {
label: string
value: number
color: string
}
export function DonutChart({ data, size = 112 }: { data: DonutSlice[]; size?: number }) {
const total = data.reduce((sum, d) => sum + d.value, 0)
let cursor = 0
const stops = data
.filter((d) => d.value > 0)
.map((d) => {
const start = cursor
cursor += (d.value / total) * 100
return `${d.color} ${start}% ${cursor}%`
})
const gradient =
total > 0
? `conic-gradient(${stops.join(', ')})`
: 'conic-gradient(var(--color-base-border, #262b36) 0% 100%)'
return (
<div className="flex items-center gap-5">
<div className="relative shrink-0" style={{ width: size, height: size }}>
<div className="h-full w-full rounded-full" style={{ background: gradient }} />
<div className="absolute inset-[18%] flex items-center justify-center rounded-full bg-base-surface">
<span className="text-sm font-semibold text-slate-200">{total.toLocaleString()}</span>
</div>
</div>
<ul className="space-y-1.5">
{data.map((d) => (
<li key={d.label} className="flex items-center gap-2 text-xs">
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: d.color }} />
<span className="text-slate-300">{d.label}</span>
<span className="text-slate-500">
{total > 0 ? Math.round((d.value / total) * 100) : 0}%
</span>
</li>
))}
</ul>
</div>
)
}
@@ -0,0 +1,32 @@
import { Outlet } from 'react-router-dom'
import { useNotificationStream } from '../../hooks/useNotificationStream'
import { usePushDeepLink } from '../../hooks/usePushDeepLink'
import { BottomNav } from './BottomNav'
import { Sidebar } from './Sidebar'
// h-[100dvh] + an internally-scrolling <main>, rather than `position:
// fixed` + a scrolling page, is deliberate: fixed-position bottom nav bars
// are notoriously unreliable on iOS (dynamic toolbar height, viewport
// quirks, inconsistent behavior between Safari/installed-PWA/other
// webviews) — a normal flex layout where the nav is just the last child
// pins it correctly everywhere without fighting any of that.
export function AppShell() {
useNotificationStream()
usePushDeepLink()
return (
<div className="flex h-[100dvh] bg-base-bg text-slate-100">
<Sidebar />
<div className="flex min-w-0 flex-1 flex-col">
{/* overflow-x-hidden is explicit, not redundant with overflow-y-auto —
per the CSS overflow spec, setting only overflow-y makes overflow-x
compute to auto too, so long unbroken content (a URL, a long word)
could otherwise make this scroll sideways within its own box. */}
<main className="safe-top flex-1 overflow-x-hidden overflow-y-auto">
<Outlet />
</main>
<BottomNav />
</div>
</div>
)
}
@@ -0,0 +1,37 @@
import { NavLink } from 'react-router-dom'
import { cn } from '../../lib/utils'
import { navItems } from './nav-items'
export function BottomNav() {
return (
<nav className="safe-bottom shrink-0 border-t border-base-border bg-base-surface/95 backdrop-blur md:hidden">
<ul className="flex items-stretch justify-around">
{navItems.map(({ to, label, icon: Icon, end }) => (
// min-w-0 is load-bearing: flex items default to min-width: auto,
// which blocks shrinking below their content's natural width —
// with 5 labeled items that's enough to push the row wider than
// narrow phone screens, cutting the last item off entirely.
<li key={to} className="min-w-0 flex-1">
<NavLink
to={to}
end={end}
className={({ isActive }) =>
cn(
'flex flex-col items-center gap-1 py-2.5 text-[11px] font-medium transition-colors',
isActive ? 'text-accent-bright' : 'text-slate-500 hover:text-slate-300',
)
}
>
{({ isActive }) => (
<>
<Icon className="h-5 w-5" strokeWidth={isActive ? 2.25 : 1.75} />
<span className="truncate">{label}</span>
</>
)}
</NavLink>
</li>
))}
</ul>
</nav>
)
}
@@ -0,0 +1,39 @@
import { NavLink } from 'react-router-dom'
import { cn } from '../../lib/utils'
import { navItems } from './nav-items'
export function Sidebar() {
return (
<aside className="hidden w-60 shrink-0 flex-col border-r border-base-border bg-base-surface md:flex">
<div className="flex items-center gap-2.5 px-5 py-5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent/15 text-accent-bright">
<span className="text-sm font-bold">N</span>
</div>
<span className="text-[15px] font-semibold tracking-tight text-slate-100">Notizarr</span>
</div>
<nav className="flex-1 px-3 py-2">
<ul className="space-y-0.5">
{navItems.map(({ to, label, icon: Icon, end }) => (
<li key={to}>
<NavLink
to={to}
end={end}
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-accent/10 text-accent-bright'
: 'text-slate-400 hover:bg-base-hover hover:text-slate-200',
)
}
>
<Icon className="h-[18px] w-[18px]" strokeWidth={1.75} />
{label}
</NavLink>
</li>
))}
</ul>
</nav>
</aside>
)
}
@@ -0,0 +1,9 @@
import { Bell, Hash, Search, Settings, Star } from 'lucide-react'
export const navItems = [
{ to: '/', label: 'Inbox', icon: Bell, end: true },
{ to: '/topics', label: 'Topics', icon: Hash, end: false },
{ to: '/search', label: 'Search', icon: Search, end: false },
{ to: '/starred', label: 'Starred', icon: Star, end: false },
{ to: '/settings', label: 'Settings', icon: Settings, end: false },
] as const
@@ -0,0 +1,156 @@
import { BellOff, Check, Star } from 'lucide-react'
import { useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { useSetNotificationState } from '../../hooks/useNotifications'
import { cn, colorForLabel, formatRelativeTime } from '../../lib/utils'
import { isQuietPriority } from '../../lib/priority'
import type { Notification } from '../../types'
const SWIPE_THRESHOLD = 64
export function NotificationRow({
notification,
showTopic = true,
}: {
notification: Notification
showTopic?: boolean
}) {
const avatarColor = colorForLabel(notification.source)
const setState = useSetNotificationState()
// Hand-rolled swipe-to-toggle-read, touch only — pointer position is
// tracked in refs (not state) so a swipe doesn't cause a re-render on
// every pixel of movement; `dragX` state only updates for the visual
// transform, and `wasSwipe` suppresses the Link's navigation on release
// so a swipe doesn't also open the notification.
const startX = useRef<number | null>(null)
const startY = useRef<number | null>(null)
const wasSwipe = useRef(false)
const [dragX, setDragX] = useState(0)
const [dragging, setDragging] = useState(false)
const handlePointerDown = (e: React.PointerEvent) => {
if (e.pointerType !== 'touch') return
startX.current = e.clientX
startY.current = e.clientY
}
const handlePointerMove = (e: React.PointerEvent) => {
if (startX.current === null || startY.current === null) return
const dx = e.clientX - startX.current
const dy = e.clientY - startY.current
if (Math.abs(dy) > Math.abs(dx) + 8) return // vertical scroll, not a swipe
setDragging(true)
setDragX(Math.max(-120, Math.min(120, dx)))
}
const endDrag = () => {
if (Math.abs(dragX) > SWIPE_THRESHOLD) {
wasSwipe.current = true
setState.mutate({ id: notification.id, changes: { read: !notification.read } })
}
startX.current = null
startY.current = null
setDragging(false)
setDragX(0)
}
const handleClick = (e: React.MouseEvent) => {
if (wasSwipe.current) {
e.preventDefault()
wasSwipe.current = false
}
}
const revealingRead = dragX !== 0
return (
<div className="relative overflow-hidden">
{revealingRead && (
<div
className={cn(
'pointer-events-none absolute inset-0 flex items-center px-5 text-xs font-medium',
dragX > 0
? 'justify-start bg-emerald-500/15 text-emerald-400'
: 'justify-end bg-emerald-500/15 text-emerald-400',
)}
>
<span className="flex items-center gap-1.5">
<Check className="h-4 w-4" />
{notification.read ? 'Mark unread' : 'Mark read'}
</span>
</div>
)}
<Link
to={`/topics/${notification.topicSlug}/messages/${notification.id}`}
onClick={handleClick}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
style={{ transform: `translateX(${dragX}px)`, transition: dragging ? 'none' : 'transform 0.2s' }}
className="group relative flex items-start gap-3 bg-base-bg px-4 py-3.5 transition-colors hover:bg-base-hover"
>
<div className="relative mt-0.5 shrink-0">
<div
className="flex h-9 w-9 items-center justify-center overflow-hidden rounded-full"
style={{ backgroundColor: avatarColor.bg }}
>
{notification.imageUrl ? (
<img src={notification.imageUrl} alt="" className="h-full w-full object-cover" />
) : (
<span className="text-xs font-semibold uppercase" style={{ color: avatarColor.fg }}>
{notification.source.slice(0, 2)}
</span>
)}
</div>
{!notification.read && (
<span className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full bg-accent ring-2 ring-base-bg" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<p
className={cn(
'truncate text-sm',
notification.read ? 'font-medium text-slate-300' : 'font-semibold text-slate-100',
)}
>
{notification.title}
</p>
{notification.starred && (
<Star className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400" />
)}
</div>
<p className="mt-0.5 line-clamp-2 text-sm text-slate-400">{notification.message}</p>
<p className="mt-1 flex items-center gap-1.5 text-xs text-slate-500">
{showTopic && (
<>
<span className="rounded bg-base-raised px-1.5 py-0.5 font-medium text-slate-400">
{notification.topicDisplayName}
</span>
<span aria-hidden>·</span>
</>
)}
<span>{formatRelativeTime(notification.createdAt)}</span>
{notification.occurrenceCount > 1 && (
<>
<span aria-hidden>·</span>
<span>×{notification.occurrenceCount}</span>
</>
)}
{isQuietPriority(notification.rawPriority) && (
<>
<span aria-hidden>·</span>
<span className="flex items-center gap-1" title="Sent without a push notification">
<BellOff className="h-3 w-3" />
</span>
</>
)}
</p>
</div>
</Link>
</div>
)
}
@@ -0,0 +1,103 @@
import { Smartphone, X } from 'lucide-react'
import { Link } from 'react-router-dom'
import { useDeleteDevice, useDevices } from '../../hooks/usePushDevices'
import { usePushSubscription } from '../../hooks/usePushSubscription'
import { formatRelativeTime } from '../../lib/utils'
import { friendlyDeviceName } from '../../lib/userAgent'
function PermissionBadge({ permission }: { permission: string }) {
const styles: Record<string, string> = {
granted: 'bg-emerald-500/15 text-emerald-400',
denied: 'bg-red-500/15 text-red-400',
default: 'bg-base-raised text-slate-400',
unsupported: 'bg-base-raised text-slate-500',
}
return (
<span className={`rounded px-1.5 py-0.5 text-xs font-medium ${styles[permission] ?? styles.default}`}>
{permission}
</span>
)
}
export function PushSection() {
const { permission, subscription, isBusy, error, enable, disable, supported } = usePushSubscription()
const { data: devices } = useDevices()
const deleteDevice = useDeleteDevice()
return (
<section className="rounded-xl border border-base-border bg-base-surface p-4 shadow-card">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-slate-200">Push notifications</h2>
<PermissionBadge permission={permission} />
</div>
{!supported && (
<p className="text-sm text-slate-500">
This browser doesn't support Web Push. On iPhone, install Notizarr to the Home Screen first
(Share → Add to Home Screen) — Safari only supports push for installed PWAs.
</p>
)}
{supported && (
<>
<p className="mb-3 text-sm text-slate-500">
{subscription
? 'Push is enabled on this device. New notifications will arrive even when Notizarr is closed.'
: 'Enable to get notified here even when the app is closed.'}
</p>
<button
type="button"
disabled={isBusy || permission === 'denied'}
onClick={() => (subscription ? disable() : enable())}
className="rounded-lg bg-accent px-3.5 py-2 text-sm font-semibold text-white transition-colors hover:bg-accent-dim disabled:opacity-50"
>
{subscription ? 'Disable on this device' : 'Enable notifications'}
</button>
{permission === 'denied' && (
<p className="mt-2 text-xs text-red-400">
Notifications are blocked for this site — re-enable them in your browser's site settings.
</p>
)}
{error && <p className="mt-2 text-xs text-red-400">{error}</p>}
</>
)}
{devices && devices.length > 0 && (
<div className="mt-4 space-y-2 border-t border-base-border pt-3">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
Devices ({devices.length})
</p>
{devices.map((device) => (
<div key={device.id} className="flex items-center gap-2.5 py-1 text-sm">
<Smartphone className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0 flex-1">
<p className="truncate text-slate-300">
{device.deviceLabel ?? friendlyDeviceName(device.userAgent)}
</p>
<p className="text-xs text-slate-500">
{device.disabledAt ? 'Disabled' : `Last seen ${formatRelativeTime(device.lastSeenAt)}`}
</p>
</div>
<button
type="button"
onClick={() => deleteDevice.mutate(device.id)}
className="rounded p-1 text-slate-500 transition-colors hover:bg-base-hover hover:text-red-400"
>
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
<Link
to="/settings/diagnostics"
className="mt-4 block text-xs font-medium text-accent-bright hover:underline"
>
Push diagnostics →
</Link>
</section>
)
}
@@ -0,0 +1,45 @@
import { Bell, BellOff, Hash } from 'lucide-react'
import { Link } from 'react-router-dom'
import { formatRelativeTime } from '../../lib/utils'
import type { Topic } from '../../types'
export function TopicCard({ topic }: { topic: Topic }) {
return (
<Link
to={`/topics/${topic.slug}`}
className="group flex items-center gap-4 rounded-xl border border-base-border bg-base-surface p-4 shadow-card transition-all hover:-translate-y-0.5 hover:border-base-border hover:bg-base-hover hover:shadow-raised"
>
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-lg"
style={{ backgroundColor: `${topic.accentColor ?? '#818cf8'}1a` }}
>
{topic.icon ? (
<span aria-hidden>{topic.icon}</span>
) : (
<Hash className="h-[18px] w-[18px]" style={{ color: topic.accentColor ?? '#818cf8' }} />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium text-slate-100">{topic.displayName}</p>
{topic.unreadCount > 0 && (
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-accent px-1.5 text-[11px] font-semibold text-white">
{topic.unreadCount}
</span>
)}
</div>
<p className="mt-0.5 truncate text-xs text-slate-500">
{topic.totalCount.toLocaleString()} total
{topic.latestAt && <> · latest {formatRelativeTime(topic.latestAt)}</>}
</p>
</div>
{topic.pushEnabled ? (
<Bell className="h-[18px] w-[18px] shrink-0 text-slate-500" strokeWidth={1.75} />
) : (
<BellOff className="h-[18px] w-[18px] shrink-0 text-slate-600" strokeWidth={1.75} />
)}
</Link>
)
}
+30
View File
@@ -0,0 +1,30 @@
import { Check } from 'lucide-react'
import { cn } from '../../lib/utils'
export function Checkbox({
checked,
onChange,
label,
}: {
checked: boolean
onChange: (checked: boolean) => void
label?: string
}) {
return (
<button
type="button"
role="checkbox"
aria-checked={checked}
aria-label={label}
onClick={() => onChange(!checked)}
className={cn(
'flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition-colors',
checked
? 'border-accent bg-accent'
: 'border-base-border bg-base-raised hover:border-slate-500',
)}
>
{checked && <Check className="h-3.5 w-3.5 text-white" strokeWidth={3} />}
</button>
)
}
+27
View File
@@ -0,0 +1,27 @@
import type { LucideIcon } from 'lucide-react'
import type { ReactNode } from 'react'
export function EmptyState({
icon: Icon,
title,
description,
action,
}: {
icon: LucideIcon
title: string
description?: string
action?: ReactNode
}) {
return (
<div className="flex flex-col items-center justify-center gap-3 px-6 py-16 text-center animate-fade-in">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-base-raised">
<Icon className="h-6 w-6 text-slate-500" strokeWidth={1.75} />
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-slate-200">{title}</p>
{description && <p className="max-w-xs text-sm text-slate-500">{description}</p>}
</div>
{action}
</div>
)
}
+21
View File
@@ -0,0 +1,21 @@
import type { ReactNode } from 'react'
export function PageHeader({
title,
subtitle,
action,
}: {
title: string
subtitle?: string
action?: ReactNode
}) {
return (
<div className="safe-top flex items-center justify-between border-b border-base-border px-4 py-4 md:px-6 md:py-5">
<div>
<h1 className="text-lg font-semibold tracking-tight text-slate-100">{title}</h1>
{subtitle && <p className="mt-0.5 text-sm text-slate-500">{subtitle}</p>}
</div>
{action}
</div>
)
}
+29
View File
@@ -0,0 +1,29 @@
import { cn } from '../../lib/utils'
export function Skeleton({ className }: { className?: string }) {
return <div className={cn('animate-pulse rounded-md bg-base-raised', className)} />
}
export function TopicCardSkeleton() {
return (
<div className="flex items-center gap-4 rounded-xl border border-base-border bg-base-surface p-4">
<Skeleton className="h-10 w-10 shrink-0 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-48" />
</div>
</div>
)
}
export function NotificationRowSkeleton() {
return (
<div className="flex items-start gap-3 px-4 py-3.5">
<Skeleton className="h-9 w-9 shrink-0 rounded-full" />
<div className="flex-1 space-y-2 pt-0.5">
<Skeleton className="h-3.5 w-40" />
<Skeleton className="h-3 w-full max-w-sm" />
</div>
</div>
)
}
+35
View File
@@ -0,0 +1,35 @@
import { cn } from '../../lib/utils'
export function Switch({
checked,
onChange,
disabled,
label,
}: {
checked: boolean
onChange: (checked: boolean) => void
disabled?: boolean
label?: string
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cn(
'relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors disabled:opacity-50',
checked ? 'bg-accent' : 'bg-base-raised',
)}
>
<span
className={cn(
'inline-block h-[18px] w-[18px] transform rounded-full bg-white shadow transition-transform',
checked ? 'translate-x-6' : 'translate-x-1',
)}
/>
</button>
)
}
+293
View File
@@ -0,0 +1,293 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { apiFetch, apiPatch, apiPost } from '../lib/api'
export interface DashboardStats {
notificationsToday: number
notificationsLast24h: number
pushAttemptsLast24h: number
pushFailuresLast24h: number
activeDevices: number
userCount: number
topicCount: number
topTopics: { slug: string; displayName: string; total: number }[]
notificationsByDay: { day: string; count: number }[]
noisiestSources: { source: string; total: number; noisy: boolean }[]
recentAuditEvents: AuditEntry[]
}
export interface AuditEntry {
id: string
actorUserId: string | null
actorTokenId: string | null
action: string
targetType: string | null
targetId: string | null
metadata: Record<string, unknown> | null
ipAddress: string | null
createdAt: string
}
export interface AdminUser {
id: string
email: string
displayName: string
role: 'admin' | 'user'
createdAt: string
lastLoginAt: string | null
}
export interface AdminDevice {
id: string
deviceLabel: string | null
userAgent: string | null
createdAt: string
lastSeenAt: string
disabledAt: string | null
userEmail: string
userDisplayName: string
}
export interface AdminToken {
id: string
name: string
tokenPrefix: string
allowedSource: string | null
allowedCidrs: string[] | null
expiresAt: string | null
revokedAt: string | null
createdAt: string
lastUsedAt: string | null
usageCount: number
}
export function useDashboardStats() {
return useQuery({
queryKey: ['admin', 'dashboard'],
queryFn: () => apiFetch<DashboardStats>('/api/v1/admin/stats/dashboard'),
refetchInterval: 30_000,
})
}
export function useAdminUsers() {
return useQuery({
queryKey: ['admin', 'users'],
queryFn: () => apiFetch<{ users: AdminUser[] }>('/api/v1/admin/users').then((r) => r.users),
})
}
export function useSetUserRole() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, role }: { id: string; role: 'admin' | 'user' }) =>
apiPatch(`/api/v1/admin/users/${id}`, { role }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }),
})
}
export function useAdminDevices() {
return useQuery({
queryKey: ['admin', 'devices'],
queryFn: () => apiFetch<{ devices: AdminDevice[] }>('/api/v1/admin/devices').then((r) => r.devices),
})
}
export interface TopicStats {
dailyCounts: { day: string; count: number }[]
topMessages: { message: string; total: number }[]
}
export function useTopicStats(slug: string | undefined) {
return useQuery({
queryKey: ['admin', 'stats', 'topics', slug],
queryFn: () => apiFetch<TopicStats>(`/api/v1/admin/stats/topics/${slug}`),
enabled: Boolean(slug),
})
}
export function useAdminSources() {
return useQuery({
queryKey: ['admin', 'sources'],
queryFn: () =>
apiFetch<{ sources: { source: string; last24h: number; noisy: boolean }[] }>(
'/api/v1/admin/sources',
).then((r) => r.sources),
})
}
export interface OidcSettings {
authMode: 'oidc' | 'proxy' | 'disabled'
issuerUrl: string
clientId: string
clientSecretSet: boolean
redirectUrl: string
scopes: string
effectiveRedirectUrl: string
}
export function useOidcSettings() {
return useQuery({
queryKey: ['admin', 'settings', 'oidc'],
queryFn: () => apiFetch<OidcSettings>('/api/v1/admin/settings/oidc'),
})
}
export function useUpdateOidcSettings() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (input: {
issuerUrl: string
clientId: string
clientSecret?: string
redirectUrl: string
scopes: string
}) => apiPatch('/api/v1/admin/settings/oidc', input),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin', 'settings', 'oidc'] }),
})
}
export interface QuietHoursSettings {
enabled: boolean
start: string
end: string
timezone: string
minPriority: number
}
export interface SystemSettings {
retentionDefaultDays: number
vapidSubject: string
quietHours: QuietHoursSettings
}
export function useSystemSettings() {
return useQuery({
queryKey: ['admin', 'settings', 'system'],
queryFn: () => apiFetch<SystemSettings>('/api/v1/admin/settings/system'),
})
}
export function useUpdateSystemSettings() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (input: Partial<SystemSettings>) => apiPatch('/api/v1/admin/settings/system', input),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin', 'settings', 'system'] }),
})
}
export interface BackupFile {
filename: string
sizeBytes: number
createdAt: string
}
export function useBackups() {
return useQuery({
queryKey: ['admin', 'backups'],
queryFn: () => apiFetch<{ backups: BackupFile[] }>('/api/v1/admin/backups').then((r) => r.backups),
refetchInterval: 30_000,
})
}
export function useCreateBackup() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => apiPost<{ backup: BackupFile }>('/api/v1/admin/backups'),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin', 'backups'] }),
})
}
export function useAuditLog() {
return useQuery({
queryKey: ['admin', 'audit-log'],
queryFn: () => apiFetch<{ entries: AuditEntry[] }>('/api/v1/admin/audit-log').then((r) => r.entries),
})
}
export function useAdminTokens() {
return useQuery({
queryKey: ['admin', 'tokens'],
queryFn: () => apiFetch<{ tokens: AdminToken[] }>('/api/v1/admin/tokens').then((r) => r.tokens),
})
}
export function useCreateToken() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (input: { name: string; allowedSource?: string; topicSlugs?: string[] }) =>
apiPost<{ token: AdminToken; plaintext: string }>('/api/v1/admin/tokens', input),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin', 'tokens'] }),
})
}
export function useRevokeToken() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => apiFetch(`/api/v1/admin/tokens/${id}`, { method: 'DELETE' }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin', 'tokens'] }),
})
}
export function useDeleteToken() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => apiFetch(`/api/v1/admin/tokens/${id}/purge`, { method: 'DELETE' }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin', 'tokens'] }),
})
}
export function useRotateToken() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiPost<{ token: AdminToken; plaintext: string }>(`/api/v1/admin/tokens/${id}/rotate`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin', 'tokens'] }),
})
}
export function useCreateTopic() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (input: { slug: string; displayName: string; description?: string }) =>
apiPost('/api/v1/admin/topics', input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['topics'] })
queryClient.invalidateQueries({ queryKey: ['admin', 'dashboard'] })
},
})
}
export function useUpdateTopic() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({
id,
changes,
}: {
id: string
changes: { retentionDays?: number | null; restricted?: boolean; archived?: boolean }
}) => apiPatch(`/api/v1/admin/topics/${id}`, changes),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['topics'] }),
})
}
export function useDeleteTopic() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => apiFetch(`/api/v1/admin/topics/${id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['topics'] })
queryClient.invalidateQueries({ queryKey: ['admin', 'dashboard'] })
},
})
}
export function useDeleteNotification() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => apiFetch(`/api/v1/notifications/${id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] })
queryClient.invalidateQueries({ queryKey: ['topics'] })
},
})
}
@@ -0,0 +1,25 @@
import { useQueryClient } from '@tanstack/react-query'
import { useEffect } from 'react'
// Subscribes to the SSE stream (docs/phase1/01-architecture.md's realtime
// design) and invalidates the relevant queries on every event, so the UI
// updates the moment a new notification arrives without polling.
export function useNotificationStream() {
const queryClient = useQueryClient()
useEffect(() => {
const source = new EventSource('/api/v1/stream')
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] })
queryClient.invalidateQueries({ queryKey: ['topics'] })
}
source.addEventListener('notification.created', invalidate)
source.addEventListener('notification.updated', invalidate)
return () => {
source.close()
}
}, [queryClient])
}
+64
View File
@@ -0,0 +1,64 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { apiFetch, apiPatch, apiPost } from '../lib/api'
import type { Notification } from '../types'
export interface NotificationFilter {
topic?: string
unread?: boolean
starred?: boolean
q?: string
}
function toQueryString(filter: NotificationFilter): string {
const params = new URLSearchParams()
if (filter.topic) params.set('topic', filter.topic)
if (filter.unread) params.set('unread', 'true')
if (filter.starred) params.set('starred', 'true')
if (filter.q) params.set('q', filter.q)
const qs = params.toString()
return qs ? `?${qs}` : ''
}
export function useNotifications(filter: NotificationFilter = {}) {
return useQuery({
queryKey: ['notifications', filter],
queryFn: () =>
apiFetch<{ notifications: Notification[] }>(`/api/v1/notifications${toQueryString(filter)}`).then(
(r) => r.notifications,
),
})
}
export function useNotification(id: string | undefined) {
return useQuery({
queryKey: ['notifications', 'detail', id],
queryFn: () => apiFetch<{ notification: Notification }>(`/api/v1/notifications/${id}`).then((r) => r.notification),
enabled: Boolean(id),
})
}
export function useMarkAllRead() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (topic?: string) =>
apiPost<{ status: string; count: number }>(
`/api/v1/notifications/mark-all-read${topic ? `?topic=${encodeURIComponent(topic)}` : ''}`,
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] })
queryClient.invalidateQueries({ queryKey: ['topics'] })
},
})
}
export function useSetNotificationState() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, changes }: { id: string; changes: { read?: boolean; starred?: boolean } }) =>
apiPatch<{ notification: Notification }>(`/api/v1/notifications/${id}`, changes),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] })
queryClient.invalidateQueries({ queryKey: ['topics'] })
},
})
}
+24
View File
@@ -0,0 +1,24 @@
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
// Handles the "app already open" half of the deep-link contract in
// docs/phase1/05-pwa-web-push-design.md: sw.ts's notificationclick handler
// focuses an existing window and postMessages it the target path instead of
// doing a full navigation itself (a service worker can't touch React
// Router's client-side history).
export function usePushDeepLink() {
const navigate = useNavigate()
useEffect(() => {
if (!('serviceWorker' in navigator)) return
const onMessage = (event: MessageEvent) => {
if (event.data?.type === 'navigate' && typeof event.data.path === 'string') {
navigate(event.data.path)
}
}
navigator.serviceWorker.addEventListener('message', onMessage)
return () => navigator.serviceWorker.removeEventListener('message', onMessage)
}, [navigate])
}
+50
View File
@@ -0,0 +1,50 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { apiFetch } from '../lib/api'
export interface Device {
id: string
deviceLabel: string | null
userAgent: string | null
createdAt: string
lastSeenAt: string
disabledAt: string | null
}
export interface DeliveryAttempt {
status: string
httpStatus: number | null
errorMessage: string | null
attemptedAt: string
}
export interface DiagnosticsDevice extends Device {
lastDeliveryAttempt: DeliveryAttempt | null
}
export function useDevices() {
return useQuery({
queryKey: ['devices'],
queryFn: () => apiFetch<{ devices: Device[] }>('/api/v1/devices').then((r) => r.devices),
})
}
export function usePushDiagnostics() {
return useQuery({
queryKey: ['push-diagnostics'],
queryFn: () =>
apiFetch<{ vapidConfigured: boolean; devices: DiagnosticsDevice[] }>(
'/api/v1/push/diagnostics',
),
})
}
export function useDeleteDevice() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => apiFetch(`/api/v1/devices/${id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['devices'] })
queryClient.invalidateQueries({ queryKey: ['push-diagnostics'] })
},
})
}
+80
View File
@@ -0,0 +1,80 @@
import { useCallback, useEffect, useState } from 'react'
import { apiFetch, apiPost } from '../lib/api'
import { isPushSupported, urlBase64ToUint8Array } from '../lib/push'
export type PushPermission = NotificationPermission | 'unsupported'
export function usePushSubscription() {
const [permission, setPermission] = useState<PushPermission>(
isPushSupported() ? Notification.permission : 'unsupported',
)
const [subscription, setSubscription] = useState<PushSubscription | null>(null)
const [isBusy, setIsBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const refresh = useCallback(async () => {
if (!isPushSupported()) return
const registration = await navigator.serviceWorker.ready
const sub = await registration.pushManager.getSubscription()
setSubscription(sub)
setPermission(Notification.permission)
}, [])
useEffect(() => {
void refresh()
}, [refresh])
const enable = useCallback(async () => {
if (!isPushSupported()) {
setError('Push is not supported in this browser.')
return
}
setIsBusy(true)
setError(null)
try {
const result = await Notification.requestPermission()
setPermission(result)
if (result !== 'granted') {
setError('Notification permission was not granted.')
return
}
const { publicKey } = await apiFetch<{ publicKey: string }>('/api/v1/push/vapid-public-key')
const registration = await navigator.serviceWorker.ready
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
})
const json = sub.toJSON()
await apiPost('/api/v1/push/subscribe', {
endpoint: json.endpoint,
keys: json.keys,
userAgent: navigator.userAgent,
})
setSubscription(sub)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to enable push notifications.')
} finally {
setIsBusy(false)
}
}, [])
const disable = useCallback(async () => {
setIsBusy(true)
setError(null)
try {
const registration = await navigator.serviceWorker.ready
const sub = await registration.pushManager.getSubscription()
await sub?.unsubscribe()
setSubscription(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to disable push notifications.')
} finally {
setIsBusy(false)
}
}, [])
return { permission, subscription, isBusy, error, enable, disable, supported: isPushSupported() }
}
+25
View File
@@ -0,0 +1,25 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { apiFetch, apiPost } from '../lib/api'
import type { SessionResponse } from '../types'
export function useSession() {
return useQuery({
queryKey: ['session'],
queryFn: () => apiFetch<SessionResponse>('/api/v1/session'),
staleTime: 60_000,
})
}
export function useLogout() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => apiPost('/api/v1/auth/logout'),
// Full reload rather than just invalidating the session query — clears
// any in-memory query cache holding data from the now-logged-out user,
// and re-runs App.tsx's session check from a clean slate.
onSuccess: () => {
queryClient.clear()
window.location.assign('/')
},
})
}
+41
View File
@@ -0,0 +1,41 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { apiFetch, apiPatch } from '../lib/api'
import type { Topic } from '../types'
export function useTopics() {
return useQuery({
queryKey: ['topics'],
queryFn: () => apiFetch<{ topics: Topic[] }>('/api/v1/topics').then((r) => r.topics),
})
}
export function useTopic(slug: string | undefined) {
return useQuery({
queryKey: ['topics', slug],
queryFn: () => apiFetch<{ topic: Topic }>(`/api/v1/topics/${slug}`).then((r) => r.topic),
enabled: Boolean(slug),
})
}
export function useSetTopicPush() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ slug, pushEnabled }: { slug: string; pushEnabled: boolean }) =>
apiPatch(`/api/v1/topics/${slug}/subscription`, { pushEnabled }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['topics'] })
},
})
}
// `minutes: null` clears an active snooze early.
export function useSnoozeTopicPush() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ slug, minutes }: { slug: string; minutes: number | null }) =>
apiPatch(`/api/v1/topics/${slug}/snooze`, { minutes }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['topics'] })
},
})
}
+66
View File
@@ -0,0 +1,66 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body,
#root {
height: 100%;
}
/* Notizarr is dark-first with no light mode, so this is unconditional —
tells the browser to use its own dark widget theme for anything it
renders itself: <select> dropdown popups, <input type="time/date">
pickers, checkboxes, scrollbars. Without it those stay light regardless
of how the surrounding elements are styled, since that part of the UI
isn't actually drawn by our CSS. */
:root {
color-scheme: dark;
}
body {
font-family:
ui-sans-serif,
system-ui,
-apple-system,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
sans-serif;
-webkit-font-smoothing: antialiased;
}
/* iOS Safari auto-zooms the page on focus for any text input under 16px,
and doesn't always zoom back out cleanly — forcing 16px here (rather
than bumping every input's Tailwind text size, which would also change
how they look) is the standard fix. !important is genuinely needed, not
just convenient: Tailwind's own size utilities (e.g. text-sm) are class
selectors, which beat a plain `input { }` element selector in the
cascade regardless of source order, so a bare rule here silently loses
to whatever text-size class an individual input uses. */
input,
textarea,
select {
font-size: 16px !important;
}
/* Safe-area padding for iPhone notch / Dynamic Island / home indicator. */
.safe-top {
padding-top: env(safe-area-inset-top);
}
.safe-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: theme('colors.base.border');
border-radius: 999px;
}
::-webkit-scrollbar-track {
background: transparent;
}
+45
View File
@@ -0,0 +1,45 @@
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
export class ApiError extends Error {
constructor(
public status: number,
public body: unknown,
) {
super(typeof body === 'object' && body && 'message' in body ? String(body.message) : `Request failed (${status})`)
}
}
// Every mutating call needs X-Requested-With — the CSRF defense described
// in docs/phase1/02-threat-model.md. Cookies are same-origin, so no
// Authorization header is needed here (that's only for LAN publishers).
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
const method = (init.method ?? 'GET').toUpperCase()
const headers = new Headers(init.headers)
if (MUTATING_METHODS.has(method)) {
headers.set('X-Requested-With', 'notizarr')
}
if (init.body && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
const res = await fetch(path, { ...init, method, headers, credentials: 'same-origin' })
if (res.status === 204) return undefined as T
const contentType = res.headers.get('content-type') ?? ''
const body = contentType.includes('application/json') ? await res.json() : await res.text()
if (!res.ok) {
throw new ApiError(res.status, body)
}
return body as T
}
export function apiPatch<T>(path: string, data: unknown): Promise<T> {
return apiFetch<T>(path, { method: 'PATCH', body: JSON.stringify(data) })
}
export function apiPost<T>(path: string, data?: unknown): Promise<T> {
return apiFetch<T>(path, { method: 'POST', body: data ? JSON.stringify(data) : undefined })
}
+20
View File
@@ -0,0 +1,20 @@
// Mirrors apps/server/src/lib/priority.ts — kept in sync by hand since
// there's no shared package wired up yet (packages/shared is still an
// empty placeholder). Only used for display here; the server is the one
// that actually decides whether to push.
const PRIORITY_ALIASES: Record<string, number> = {
min: 1,
low: 2,
default: 3,
high: 4,
urgent: 5,
max: 5,
}
export function isQuietPriority(raw: string | null | undefined): boolean {
if (!raw) return false
const trimmed = raw.trim().toLowerCase()
// The `in` check guarantees the key exists.
const value = trimmed in PRIORITY_ALIASES ? PRIORITY_ALIASES[trimmed]! : Number(trimmed)
return Number.isFinite(value) && value <= 1
}
+23
View File
@@ -0,0 +1,23 @@
// PushManager.subscribe() needs the VAPID public key as a raw Uint8Array,
// but the server hands it over base64url-encoded — this is the standard
// conversion (documented in every Web Push tutorial, not Notizarr-specific).
export function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = atob(base64)
// Uint8Array.from()'s inferred type is the looser Uint8Array<ArrayBufferLike>
// (which also covers SharedArrayBuffer) — this call always backs onto a
// plain ArrayBuffer, which is what PushManager.subscribe() requires.
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0))) as Uint8Array<ArrayBuffer>
}
export function isPushSupported(): boolean {
return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window
}
export function isStandalone(): boolean {
return (
window.matchMedia('(display-mode: standalone)').matches ||
(navigator as unknown as { standalone?: boolean }).standalone === true
)
}
+27
View File
@@ -0,0 +1,27 @@
// Client-side-only, display-purpose UA parsing — not stored, computed
// fresh from the raw userAgent string wherever a device is listed. No
// library: a handful of substring checks covers the realistic homelab
// device set without pulling in a UA-parsing dependency for this.
export function friendlyDeviceName(userAgent: string | null | undefined): string {
if (!userAgent) return 'Unknown device'
const ua = userAgent
let device: string
if (/iPhone/.test(ua)) device = 'iPhone'
else if (/iPad/.test(ua)) device = 'iPad'
else if (/Android/.test(ua)) device = 'Android'
else if (/Macintosh/.test(ua)) device = 'Mac'
else if (/Windows/.test(ua)) device = 'Windows PC'
else if (/Linux/.test(ua)) device = 'Linux PC'
else device = 'Device'
let browser: string | null = null
if (/EdgA?\//.test(ua)) browser = 'Edge'
else if (/OPR\//.test(ua)) browser = 'Opera'
else if (/CriOS|Chrome\//.test(ua)) browser = 'Chrome'
else if (/FxiOS|Firefox\//.test(ua)) browser = 'Firefox'
else if (/Version\/.*Safari/.test(ua)) browser = 'Safari'
return browser ? `${device} (${browser})` : device
}
+54
View File
@@ -0,0 +1,54 @@
export function cn(...values: Array<string | false | null | undefined>): string {
return values.filter(Boolean).join(' ')
}
const RELATIVE_UNITS: Array<[Intl.RelativeTimeFormatUnit, number]> = [
['year', 1000 * 60 * 60 * 24 * 365],
['month', 1000 * 60 * 60 * 24 * 30],
['week', 1000 * 60 * 60 * 24 * 7],
['day', 1000 * 60 * 60 * 24],
['hour', 1000 * 60 * 60],
['minute', 1000 * 60],
]
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
export function formatRelativeTime(dateInput: string | Date): string {
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput
const diffMs = date.getTime() - Date.now()
const absMs = Math.abs(diffMs)
if (absMs < 30_000) return 'just now'
for (const [unit, unitMs] of RELATIVE_UNITS) {
if (absMs >= unitMs) {
return rtf.format(Math.round(diffMs / unitMs), unit)
}
}
return rtf.format(Math.round(diffMs / 1000), 'second')
}
export function formatExactTime(dateInput: string | Date): string {
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(date)
}
// Deterministic, muted accent color per source/topic string, so avatar
// circles in a list read as visually distinct at a glance instead of one
// flat gray repeated down the page. Fixed saturation/lightness keeps every
// result legible against the dark background.
export function colorForLabel(label: string): { bg: string; fg: string } {
let hash = 0
for (let i = 0; i < label.length; i++) {
hash = (hash << 5) - hash + label.charCodeAt(i)
hash |= 0
}
const hue = Math.abs(hash) % 360
return {
bg: `hsl(${hue} 55% 22%)`,
fg: `hsl(${hue} 85% 78%)`,
}
}
+30
View File
@@ -0,0 +1,30 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
const rootElement = document.getElementById('root')
if (!rootElement) {
throw new Error('Missing #root element')
}
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 10_000,
retry: 1,
},
},
})
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
)
+126
View File
@@ -0,0 +1,126 @@
import { ArrowLeft, CheckCircle2, XCircle } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { PageHeader } from '../components/ui/PageHeader'
import { usePushDiagnostics } from '../hooks/usePushDevices'
import { usePushSubscription } from '../hooks/usePushSubscription'
import { isPushSupported, isStandalone } from '../lib/push'
import { formatExactTime } from '../lib/utils'
import { friendlyDeviceName } from '../lib/userAgent'
function Row({ label, ok, detail }: { label: string; ok: boolean | null; detail: string }) {
return (
<div className="flex items-center justify-between gap-3 border-b border-base-border py-3 last:border-0">
<div>
<p className="text-sm text-slate-200">{label}</p>
<p className="text-xs text-slate-500">{detail}</p>
</div>
{ok === null ? (
<span className="h-5 w-5 rounded-full bg-base-raised" />
) : ok ? (
<CheckCircle2 className="h-5 w-5 shrink-0 text-emerald-400" />
) : (
<XCircle className="h-5 w-5 shrink-0 text-red-400" />
)}
</div>
)
}
// Makes iOS troubleshooting tractable, per docs/phase1/05-pwa-web-push-design.md
// — this is deliberately explicit about what Web Push *can't* guarantee
// rather than implying certainty it doesn't have.
export function DiagnosticsPage() {
const { permission, subscription, supported } = usePushSubscription()
const { data: diagnostics } = usePushDiagnostics()
const [swActive, setSwActive] = useState<boolean | null>(null)
useEffect(() => {
if (!('serviceWorker' in navigator)) {
setSwActive(false)
return
}
navigator.serviceWorker.getRegistration().then((reg) => {
setSwActive(Boolean(reg?.active))
})
}, [])
return (
<div className="mx-auto max-w-xl">
<div className="safe-top flex items-center gap-3 border-b border-base-border px-4 py-4 md:px-6 md:py-5">
<Link
to="/settings"
className="rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-base-hover hover:text-slate-200"
>
<ArrowLeft className="h-5 w-5" />
</Link>
<h1 className="text-lg font-semibold tracking-tight text-slate-100">Push diagnostics</h1>
</div>
<div className="px-4 py-2 md:px-6">
<section className="rounded-xl border border-base-border bg-base-surface px-4 shadow-card">
<Row
label="Installed as app"
ok={isStandalone()}
detail={isStandalone() ? 'Running standalone' : 'Running in a browser tab'}
/>
<Row
label="Service worker"
ok={swActive}
detail={swActive === null ? 'Checking…' : swActive ? 'Active' : 'Not registered'}
/>
<Row
label="Push supported"
ok={supported}
detail={supported ? 'Browser supports the Push API' : 'Not supported in this browser'}
/>
<Row
label="Notification permission"
ok={permission === 'granted' ? true : permission === 'denied' ? false : null}
detail={permission}
/>
<Row
label="Push subscription"
ok={Boolean(subscription)}
detail={subscription ? 'Active on this device' : 'Not subscribed on this device'}
/>
<Row
label="Server VAPID configuration"
ok={diagnostics?.vapidConfigured ?? null}
detail={diagnostics?.vapidConfigured ? 'Configured' : 'Checking…'}
/>
</section>
{diagnostics && diagnostics.devices.length > 0 && (
<section className="mt-4 rounded-xl border border-base-border bg-base-surface p-4 shadow-card">
<h2 className="mb-3 text-sm font-semibold text-slate-200">Delivery history</h2>
<div className="space-y-3">
{diagnostics.devices.map((device) => (
<div key={device.id} className="text-sm">
<p className="text-slate-300">
{device.deviceLabel ?? friendlyDeviceName(device.userAgent)}
</p>
{device.lastDeliveryAttempt ? (
<p className="text-xs text-slate-500">
Last attempt: {device.lastDeliveryAttempt.status} at{' '}
{formatExactTime(device.lastDeliveryAttempt.attemptedAt)}
{device.lastDeliveryAttempt.errorMessage &&
` — ${device.lastDeliveryAttempt.errorMessage}`}
</p>
) : (
<p className="text-xs text-slate-500">No delivery attempts yet</p>
)}
</div>
))}
</div>
</section>
)}
<p className="mt-4 px-1 pb-6 text-xs leading-relaxed text-slate-500">
Web Push confirms the push service accepted a message — it can't guarantee the OS actually
displayed it on screen. On iPhone, push requires iOS 16.4+ and Notizarr must be installed to
the Home Screen first; Safari does not support push for ordinary browser tabs.
</p>
</div>
</div>
)
}
+59
View File
@@ -0,0 +1,59 @@
import { CheckCheck, Inbox } from 'lucide-react'
import { useMarkAllRead, useNotifications } from '../hooks/useNotifications'
import { NotificationRow } from '../components/notifications/NotificationRow'
import { NotificationRowSkeleton } from '../components/ui/Skeleton'
import { EmptyState } from '../components/ui/EmptyState'
import { PageHeader } from '../components/ui/PageHeader'
export function InboxPage() {
const { data: notifications, isLoading } = useNotifications()
const markAllRead = useMarkAllRead()
const hasUnread = notifications?.some((n) => !n.read) ?? false
return (
<div>
<PageHeader
title="Inbox"
subtitle="Every notification, newest first"
action={
hasUnread && (
<button
type="button"
disabled={markAllRead.isPending}
onClick={() => markAllRead.mutate(undefined)}
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 disabled:opacity-50"
>
<CheckCheck className="h-3.5 w-3.5" />
Mark all read
</button>
)
}
/>
{isLoading && (
<div className="divide-y divide-base-border">
{Array.from({ length: 6 }).map((_, i) => (
<NotificationRowSkeleton key={i} />
))}
</div>
)}
{!isLoading && notifications?.length === 0 && (
<EmptyState
icon={Inbox}
title="No notifications yet"
description="Publish to a topic from a LAN service and it'll show up here instantly."
/>
)}
{!isLoading && notifications && notifications.length > 0 && (
<div className="divide-y divide-base-border animate-fade-in">
{notifications.map((n) => (
<NotificationRow key={n.id} notification={n} />
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,138 @@
import { ArrowLeft, BellOff, ExternalLink, Star, Trash2 } from 'lucide-react'
import { useEffect } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { Skeleton } from '../components/ui/Skeleton'
import { useDeleteNotification } from '../hooks/useAdmin'
import { useNotification, useSetNotificationState } from '../hooks/useNotifications'
import { useSession } from '../hooks/useSession'
import { isQuietPriority } from '../lib/priority'
import { formatExactTime, formatRelativeTime } from '../lib/utils'
export function NotificationDetailPage() {
const { slug, id } = useParams<{ slug: string; id: string }>()
const navigate = useNavigate()
const { data: session } = useSession()
const { data: notification, isLoading } = useNotification(id)
const setState = useSetNotificationState()
const deleteNotification = useDeleteNotification()
const isAdmin = session?.user?.role === 'admin'
useEffect(() => {
if (notification && !notification.read) {
setState.mutate({ id: notification.id, changes: { read: true } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [notification?.id])
return (
<div className="mx-auto max-w-2xl">
<div className="safe-top flex items-center justify-between border-b border-base-border px-4 py-4 md:px-6 md:py-5">
<Link
to={`/topics/${slug}`}
className="flex items-center gap-1.5 text-sm text-slate-400 transition-colors hover:text-slate-200"
>
<ArrowLeft className="h-4 w-4" />
Back
</Link>
{notification && (
<div className="flex items-center gap-1">
<button
type="button"
onClick={() =>
setState.mutate({ id: notification.id, changes: { starred: !notification.starred } })
}
className="rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-base-hover hover:text-amber-400"
>
<Star
className={notification.starred ? 'h-5 w-5 fill-amber-400 text-amber-400' : 'h-5 w-5'}
/>
</button>
{isAdmin && (
<button
type="button"
disabled={deleteNotification.isPending}
onClick={() => {
if (window.confirm('Delete this notification? This can\'t be undone.')) {
deleteNotification.mutate(notification.id, {
onSuccess: () => navigate(`/topics/${slug}`),
})
}
}}
className="rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-red-500/10 hover:text-red-400"
>
<Trash2 className="h-5 w-5" />
</button>
)}
</div>
)}
</div>
{isLoading && (
<div className="space-y-3 px-4 py-6 md:px-6">
<Skeleton className="h-6 w-2/3" />
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-24 w-full" />
</div>
)}
{notification && (
<div className="animate-slide-up space-y-5 px-4 py-6 md:px-6">
<div>
<div className="mb-1.5 flex items-center gap-2 text-xs text-slate-500">
<span className="rounded bg-base-raised px-1.5 py-0.5 font-medium text-slate-400">
{notification.topicDisplayName}
</span>
<span aria-hidden>·</span>
<span>{notification.source}</span>
{notification.occurrenceCount > 1 && (
<>
<span aria-hidden>·</span>
<span>occurred ×{notification.occurrenceCount}</span>
</>
)}
{isQuietPriority(notification.rawPriority) && (
<>
<span aria-hidden>·</span>
<span className="flex items-center gap-1">
<BellOff className="h-3 w-3" />
no push sent
</span>
</>
)}
</div>
<h1 className="text-xl font-semibold tracking-tight text-slate-100">
{notification.title}
</h1>
<p className="mt-1 text-sm text-slate-500" title={formatExactTime(notification.createdAt)}>
{formatExactTime(notification.createdAt)} · {formatRelativeTime(notification.createdAt)}
</p>
</div>
{notification.imageUrl && (
<img
src={notification.imageUrl}
alt=""
className="max-h-96 w-full rounded-xl border border-base-border object-cover"
/>
)}
<p className="whitespace-pre-wrap text-[15px] leading-relaxed text-slate-200">
{notification.message}
</p>
{notification.externalUrl && (
<a
href={notification.externalUrl}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1.5 rounded-lg border border-base-border bg-base-surface px-3 py-2 text-sm font-medium text-accent-bright transition-colors hover:bg-base-hover"
>
Open link
<ExternalLink className="h-3.5 w-3.5" />
</a>
)}
</div>
)}
</div>
)
}
+60
View File
@@ -0,0 +1,60 @@
import { Search as SearchIcon } from 'lucide-react'
import { useState } from 'react'
import { NotificationRow } from '../components/notifications/NotificationRow'
import { EmptyState } from '../components/ui/EmptyState'
import { NotificationRowSkeleton } from '../components/ui/Skeleton'
import { useNotifications } from '../hooks/useNotifications'
export function SearchPage() {
const [query, setQuery] = useState('')
const { data: notifications, isLoading, isFetching } = useNotifications(
query.trim().length > 0 ? { q: query.trim() } : {},
)
const hasQuery = query.trim().length > 0
return (
<div>
<div className="safe-top border-b border-base-border px-4 py-4 md:px-6 md:py-5">
<h1 className="mb-3 text-lg font-semibold tracking-tight text-slate-100">Search</h1>
<div className="relative">
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search titles and messages…"
className="w-full rounded-lg border border-base-border bg-base-surface py-2.5 pl-9 pr-3 text-sm text-slate-100 placeholder:text-slate-500 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
/>
</div>
</div>
{!hasQuery && (
<EmptyState
icon={SearchIcon}
title="Search your notification history"
description="Matches titles and messages across every topic you can see."
/>
)}
{hasQuery && isLoading && (
<div className="divide-y divide-base-border">
{Array.from({ length: 4 }).map((_, i) => (
<NotificationRowSkeleton key={i} />
))}
</div>
)}
{hasQuery && !isLoading && notifications?.length === 0 && !isFetching && (
<EmptyState icon={SearchIcon} title={`No results for "${query.trim()}"`} />
)}
{hasQuery && notifications && notifications.length > 0 && (
<div className="divide-y divide-base-border animate-fade-in">
{notifications.map((n) => (
<NotificationRow key={n.id} notification={n} />
))}
</div>
)}
</div>
)
}
+65
View File
@@ -0,0 +1,65 @@
import { LogOut, ShieldCheck } from 'lucide-react'
import { Link } from 'react-router-dom'
import { useLogout, useSession } from '../hooks/useSession'
import { PageHeader } from '../components/ui/PageHeader'
import { PushSection } from '../components/settings/PushSection'
export function SettingsPage() {
const { data: session } = useSession()
const logout = useLogout()
return (
<div>
<PageHeader title="Settings" />
<div className="space-y-6 px-4 py-5 md:px-6">
<section className="rounded-xl border border-base-border bg-base-surface p-4 shadow-card">
<h2 className="mb-3 text-sm font-semibold text-slate-200">Profile</h2>
<dl className="space-y-2 text-sm">
<div className="flex justify-between gap-4">
<dt className="text-slate-500">Name</dt>
<dd className="text-slate-200">{session?.user?.displayName ?? '—'}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-slate-500">Email</dt>
<dd className="text-slate-200">{session?.user?.email ?? '—'}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-slate-500">Role</dt>
<dd className="capitalize text-slate-200">{session?.user?.role ?? '—'}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-slate-500">Auth mode</dt>
<dd className="text-slate-200">{session?.authMode ?? '—'}</dd>
</div>
</dl>
</section>
<PushSection />
{session?.user?.role === 'admin' && (
<Link
to="/admin"
className="flex items-center gap-3 rounded-xl border border-base-border bg-base-surface p-4 shadow-card transition-colors hover:bg-base-hover"
>
<ShieldCheck className="h-5 w-5 text-accent-bright" />
<div className="flex-1">
<p className="text-sm font-medium text-slate-100">Admin</p>
<p className="text-xs text-slate-500">Topics, tokens, users, devices, audit log</p>
</div>
</Link>
)}
<button
type="button"
disabled={logout.isPending}
onClick={() => logout.mutate()}
className="flex w-full items-center gap-3 rounded-xl border border-base-border bg-base-surface p-4 text-left shadow-card transition-colors hover:bg-red-500/10 disabled:opacity-50"
>
<LogOut className="h-5 w-5 text-red-400" />
<p className="text-sm font-medium text-red-400">Log out</p>
</button>
</div>
</div>
)
}
+40
View File
@@ -0,0 +1,40 @@
import { Star } from 'lucide-react'
import { NotificationRow } from '../components/notifications/NotificationRow'
import { EmptyState } from '../components/ui/EmptyState'
import { PageHeader } from '../components/ui/PageHeader'
import { NotificationRowSkeleton } from '../components/ui/Skeleton'
import { useNotifications } from '../hooks/useNotifications'
export function StarredPage() {
const { data: notifications, isLoading } = useNotifications({ starred: true })
return (
<div>
<PageHeader title="Starred" subtitle="Notifications you've flagged" />
{isLoading && (
<div className="divide-y divide-base-border">
{Array.from({ length: 4 }).map((_, i) => (
<NotificationRowSkeleton key={i} />
))}
</div>
)}
{!isLoading && notifications?.length === 0 && (
<EmptyState
icon={Star}
title="Nothing starred yet"
description="Tap the star on any notification to pin it here."
/>
)}
{!isLoading && notifications && notifications.length > 0 && (
<div className="divide-y divide-base-border animate-fade-in">
{notifications.map((n) => (
<NotificationRow key={n.id} notification={n} />
))}
</div>
)}
</div>
)
}
+122
View File
@@ -0,0 +1,122 @@
import { AlarmClock, ArrowLeft, CheckCheck, Inbox } from 'lucide-react'
import { Link, useParams } from 'react-router-dom'
import { NotificationRow } from '../components/notifications/NotificationRow'
import { EmptyState } from '../components/ui/EmptyState'
import { NotificationRowSkeleton } from '../components/ui/Skeleton'
import { Switch } from '../components/ui/Switch'
import { useMarkAllRead, useNotifications } from '../hooks/useNotifications'
import { useSetTopicPush, useSnoozeTopicPush, useTopic } from '../hooks/useTopics'
const SNOOZE_OPTIONS = [
{ label: '1 hour', minutes: 60 },
{ label: '4 hours', minutes: 240 },
{ label: '8 hours', minutes: 480 },
{ label: '24 hours', minutes: 1440 },
]
export function TopicDetailPage() {
const { slug } = useParams<{ slug: string }>()
const { data: topic } = useTopic(slug)
const { data: notifications, isLoading } = useNotifications({ topic: slug })
const setPush = useSetTopicPush()
const snoozePush = useSnoozeTopicPush()
const markAllRead = useMarkAllRead()
const hasUnread = notifications?.some((n) => !n.read) ?? false
const snoozedUntil = topic?.mutedUntil && new Date(topic.mutedUntil) > new Date() ? topic.mutedUntil : null
return (
<div>
<div className="safe-top flex items-center justify-between gap-3 border-b border-base-border px-4 py-4 md:px-6 md:py-5">
<div className="flex min-w-0 items-center gap-3">
<Link
to="/topics"
className="rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-base-hover hover:text-slate-200 md:hidden"
>
<ArrowLeft className="h-5 w-5" />
</Link>
<div className="min-w-0">
<h1 className="truncate text-lg font-semibold tracking-tight text-slate-100">
{topic?.displayName ?? slug}
</h1>
{topic?.description && (
<p className="mt-0.5 truncate text-sm text-slate-500">{topic.description}</p>
)}
</div>
</div>
{slug && (
<div className="flex shrink-0 items-center gap-2.5">
{hasUnread && (
<button
type="button"
disabled={markAllRead.isPending}
onClick={() => markAllRead.mutate(slug)}
className="flex items-center gap-1.5 rounded-lg border border-base-border px-2.5 py-1.5 text-xs font-medium text-slate-400 transition-colors hover:bg-base-hover hover:text-slate-200 disabled:opacity-50"
>
<CheckCheck className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Mark all read</span>
</button>
)}
<label className="flex items-center gap-1.5 text-xs text-slate-400">
<AlarmClock className="h-3.5 w-3.5" />
<select
value=""
disabled={snoozePush.isPending}
onChange={(e) => {
const value = e.target.value
if (value === 'clear') snoozePush.mutate({ slug, minutes: null })
else if (value) snoozePush.mutate({ slug, minutes: Number(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>
{snoozedUntil
? `Snoozed until ${new Date(snoozedUntil).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
: 'Snooze push…'}
</option>
{snoozedUntil && <option value="clear">Clear snooze</option>}
{SNOOZE_OPTIONS.map((opt) => (
<option key={opt.minutes} value={opt.minutes}>
{snoozedUntil ? `Snooze ${opt.label} more` : `Snooze ${opt.label}`}
</option>
))}
</select>
</label>
<span className="hidden text-sm text-slate-400 sm:inline">Push</span>
<Switch
checked={topic?.pushEnabled ?? true}
disabled={setPush.isPending}
onChange={(pushEnabled) => setPush.mutate({ slug, pushEnabled })}
label="Push notifications for this topic"
/>
</div>
)}
</div>
{isLoading && (
<div className="divide-y divide-base-border">
{Array.from({ length: 6 }).map((_, i) => (
<NotificationRowSkeleton key={i} />
))}
</div>
)}
{!isLoading && notifications?.length === 0 && (
<EmptyState
icon={Inbox}
title="No notifications in this topic yet"
description="Once something publishes here, it'll show up instantly."
/>
)}
{!isLoading && notifications && notifications.length > 0 && (
<div className="divide-y divide-base-border animate-fade-in">
{notifications.map((n) => (
<NotificationRow key={n.id} notification={n} showTopic={false} />
))}
</div>
)}
</div>
)
}
+32
View File
@@ -0,0 +1,32 @@
import { Hash } from 'lucide-react'
import { useTopics } from '../hooks/useTopics'
import { TopicCard } from '../components/topics/TopicCard'
import { TopicCardSkeleton } from '../components/ui/Skeleton'
import { EmptyState } from '../components/ui/EmptyState'
import { PageHeader } from '../components/ui/PageHeader'
export function TopicsPage() {
const { data: topics, isLoading } = useTopics()
return (
<div className="px-4 md:px-6">
<PageHeader title="Topics" subtitle="Everything publishing to Notizarr" />
<div className="space-y-2 py-4">
{isLoading &&
Array.from({ length: 4 }).map((_, i) => <TopicCardSkeleton key={i} />)}
{!isLoading && topics?.length === 0 && (
<EmptyState
icon={Hash}
title="No topics yet"
description="An admin can create one from Admin → Topics, or it'll appear automatically the first time a LAN service publishes to it."
/>
)}
{!isLoading &&
topics?.map((topic) => <TopicCard key={topic.id} topic={topic} />)}
</div>
</div>
)
}
+57
View File
@@ -0,0 +1,57 @@
import { ArrowLeft } from 'lucide-react'
import { Link, NavLink, Outlet } from 'react-router-dom'
import { cn } from '../../lib/utils'
const tabs = [
{ to: '/admin', label: 'Dashboard', end: true },
{ to: '/admin/topics', label: 'Topics' },
{ to: '/admin/tokens', label: 'API Tokens' },
{ to: '/admin/users', label: 'Users' },
{ to: '/admin/devices', label: 'Devices' },
{ to: '/admin/sources', label: 'Sources' },
{ to: '/admin/auth', label: 'Auth' },
{ to: '/admin/system', label: 'System' },
{ to: '/admin/backups', label: 'Backups' },
{ to: '/admin/audit-log', label: 'Audit Log' },
]
export function AdminLayout() {
return (
<div>
<div className="safe-top border-b border-base-border px-4 py-4 md:px-6 md:py-5">
<Link
to="/settings"
className="mb-2 inline-flex items-center gap-1.5 text-xs text-slate-500 transition-colors hover:text-slate-300"
>
<ArrowLeft className="h-3.5 w-3.5" />
Back to app
</Link>
<h1 className="text-lg font-semibold tracking-tight text-slate-100">Admin</h1>
</div>
<div className="scrollbar-none flex gap-1 overflow-x-auto border-b border-base-border px-4 md:px-6">
{tabs.map((tab) => (
<NavLink
key={tab.to}
to={tab.to}
end={tab.end}
className={({ isActive }) =>
cn(
'shrink-0 border-b-2 px-3 py-2.5 text-sm font-medium transition-colors',
isActive
? 'border-accent text-accent-bright'
: 'border-transparent text-slate-500 hover:text-slate-300',
)
}
>
{tab.label}
</NavLink>
))}
</div>
<div className="px-4 py-5 md:px-6">
<Outlet />
</div>
</div>
)
}
+40
View File
@@ -0,0 +1,40 @@
import { useAuditLog } from '../../hooks/useAdmin'
import { formatExactTime } from '../../lib/utils'
export function AuditLogPage() {
const { data: entries } = useAuditLog()
return (
<div className="overflow-x-auto rounded-xl border border-base-border bg-base-surface shadow-card">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-base-border text-xs uppercase tracking-wide text-slate-500">
<th className="px-4 py-3 font-medium">Action</th>
<th className="px-4 py-3 font-medium">Target</th>
<th className="px-4 py-3 font-medium">IP</th>
<th className="px-4 py-3 font-medium">When</th>
</tr>
</thead>
<tbody>
{entries?.map((entry) => (
<tr key={entry.id} className="border-b border-base-border last:border-0">
<td className="px-4 py-3 font-medium text-slate-200">{entry.action}</td>
<td className="px-4 py-3 text-slate-400">
{entry.targetType ? `${entry.targetType}:${entry.targetId}` : '—'}
</td>
<td className="px-4 py-3 text-slate-400">{entry.ipAddress ?? '—'}</td>
<td className="px-4 py-3 text-slate-500">{formatExactTime(entry.createdAt)}</td>
</tr>
))}
{entries?.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-6 text-center text-slate-500">
Nothing logged yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
)
}
@@ -0,0 +1,147 @@
import { type FormEvent, useEffect, useState } from 'react'
import { useOidcSettings, useUpdateOidcSettings } from '../../hooks/useAdmin'
const AUTH_MODE_LABEL: Record<string, string> = {
oidc: 'OIDC',
proxy: 'Trusted proxy header',
disabled: 'Disabled (dev only)',
}
export function AuthSettingsAdminPage() {
const { data: settings } = useOidcSettings()
const updateSettings = useUpdateOidcSettings()
const [issuerUrl, setIssuerUrl] = useState('')
const [clientId, setClientId] = useState('')
const [clientSecret, setClientSecret] = useState('')
const [redirectUrl, setRedirectUrl] = useState('')
const [scopes, setScopes] = useState('')
const [formError, setFormError] = useState<string | null>(null)
const [saved, setSaved] = useState(false)
// Seed the form once settings load — a plain effect rather than syncing on
// every refetch, so an admin's in-progress edits aren't clobbered by the
// background refetch after a save.
useEffect(() => {
if (!settings) return
setIssuerUrl(settings.issuerUrl)
setClientId(settings.clientId)
setRedirectUrl(settings.redirectUrl || settings.effectiveRedirectUrl)
setScopes(settings.scopes || 'openid profile email')
}, [settings])
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
setFormError(null)
setSaved(false)
updateSettings.mutate(
{
issuerUrl,
clientId,
clientSecret: clientSecret.trim() ? clientSecret.trim() : undefined,
redirectUrl,
scopes,
},
{
onSuccess: () => {
setClientSecret('')
setSaved(true)
},
onError: (err) => setFormError(err instanceof Error ? err.message : 'Failed to save settings'),
},
)
}
return (
<div className="space-y-6">
<div className="rounded-xl border border-base-border bg-base-surface p-4 shadow-card">
<p className="text-sm font-medium text-slate-200">
Login mode: {settings ? (AUTH_MODE_LABEL[settings.authMode] ?? settings.authMode) : '…'}
</p>
<p className="mt-1 text-xs leading-relaxed text-slate-500">
Switching login modes changes which routes the server exposes at startup, so it's set via the{' '}
<code className="rounded bg-base-raised px-1 py-0.5">AUTH_MODE</code> environment variable and
needs a restart. The OIDC provider details below can be changed here at any time without a
restart — they take effect on the next login attempt.
</p>
</div>
<form
onSubmit={handleSubmit}
className="space-y-4 rounded-xl border border-base-border bg-base-surface p-4 shadow-card"
>
<div className="space-y-1">
<label className="text-xs font-medium text-slate-400">Issuer URL</label>
<input
required
value={issuerUrl}
onChange={(e) => setIssuerUrl(e.target.value)}
placeholder="https://id.example.com"
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="space-y-1">
<label className="text-xs font-medium text-slate-400">Client ID</label>
<input
required
value={clientId}
onChange={(e) => setClientId(e.target.value)}
placeholder="notizarr"
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="space-y-1">
<label className="text-xs font-medium text-slate-400">Client secret</label>
<input
type="password"
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
placeholder={settings?.clientSecretSet ? 'Leave blank to keep the current secret' : 'Not set'}
autoComplete="off"
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="space-y-1">
<label className="text-xs font-medium text-slate-400">Redirect URL</label>
<input
required
value={redirectUrl}
onChange={(e) => setRedirectUrl(e.target.value)}
placeholder={settings?.effectiveRedirectUrl}
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"
/>
<p className="text-xs text-slate-600">
Register this exact URL as an allowed redirect URI in your identity provider's client
settings.
</p>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-slate-400">Scopes</label>
<input
required
value={scopes}
onChange={(e) => setScopes(e.target.value)}
placeholder="openid profile email"
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 items-center gap-3">
<button
type="submit"
disabled={updateSettings.isPending}
className="rounded-lg bg-accent px-3.5 py-2 text-sm font-semibold text-white transition-colors hover:bg-accent-dim disabled:opacity-50"
>
Save
</button>
{saved && !formError && <p className="text-xs text-emerald-400">Saved.</p>}
{formError && <p className="text-xs text-red-400">{formError}</p>}
</div>
</form>
</div>
)
}
@@ -0,0 +1,67 @@
import { Database, Download } from 'lucide-react'
import { useBackups, useCreateBackup } from '../../hooks/useAdmin'
import { formatRelativeTime } from '../../lib/utils'
function formatSize(bytes: number): string {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export function BackupsAdminPage() {
const { data: backups } = useBackups()
const createBackup = useCreateBackup()
return (
<div className="space-y-4">
<div className="flex items-center justify-between rounded-xl border border-base-border bg-base-surface p-4 shadow-card">
<div>
<p className="text-sm font-medium text-slate-200">Database backups</p>
<p className="mt-0.5 text-xs text-slate-500">
Runs automatically once a day and keeps the last 14. A full <code className="rounded bg-base-raised px-1 py-0.5">pg_dump</code> covers everything — history, users, topics, tokens, the VAPID keypair.
</p>
</div>
<button
type="button"
disabled={createBackup.isPending}
onClick={() => createBackup.mutate()}
className="flex shrink-0 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"
>
<Database className="h-4 w-4" />
{createBackup.isPending ? 'Backing up…' : 'Backup now'}
</button>
</div>
{createBackup.isError && (
<p className="text-xs text-red-400">
{createBackup.error instanceof Error ? createBackup.error.message : 'Backup failed.'}
</p>
)}
<div className="space-y-2">
{backups?.length === 0 && (
<p className="text-sm text-slate-500">No backups yet — the daily job hasn't run.</p>
)}
{backups?.map((b) => (
<div
key={b.filename}
className="flex flex-wrap items-center gap-3 rounded-xl border border-base-border bg-base-surface p-4 shadow-card"
>
<div className="min-w-40 flex-1">
<p className="truncate font-mono text-xs text-slate-300">{b.filename}</p>
<p className="mt-0.5 text-xs text-slate-500">
{formatSize(b.sizeBytes)} · {formatRelativeTime(b.createdAt)}
</p>
</div>
<a
href={`/api/v1/admin/backups/${encodeURIComponent(b.filename)}/download`}
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"
>
<Download className="h-3.5 w-3.5" />
Download
</a>
</div>
))}
</div>
</div>
)
}
+136
View File
@@ -0,0 +1,136 @@
import { DailyBarChart } from '../../components/admin/DailyBarChart'
import { DonutChart } from '../../components/admin/DonutChart'
import { useDashboardStats } from '../../hooks/useAdmin'
import { colorForLabel, formatRelativeTime } from '../../lib/utils'
function StatCard({ label, value }: { label: string; value: number | string }) {
return (
<div className="rounded-xl border border-base-border bg-base-surface p-4 shadow-card">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">{label}</p>
<p className="mt-1 text-2xl font-semibold tracking-tight text-slate-100">{value}</p>
</div>
)
}
export function DashboardPage() {
const { data: stats, isLoading } = useDashboardStats()
if (isLoading || !stats) {
return <p className="text-sm text-slate-500">Loading…</p>
}
const pushSuccessRate =
stats.pushAttemptsLast24h === 0
? null
: Math.round(
((stats.pushAttemptsLast24h - stats.pushFailuresLast24h) / stats.pushAttemptsLast24h) * 100,
)
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<StatCard label="Today" value={stats.notificationsToday} />
<StatCard label="Last 24h" value={stats.notificationsLast24h} />
<StatCard label="Push attempts (24h)" value={stats.pushAttemptsLast24h} />
<StatCard
label="Push success rate"
value={pushSuccessRate === null ? '—' : `${pushSuccessRate}%`}
/>
<StatCard label="Active devices" value={stats.activeDevices} />
<StatCard label="Users / Topics" value={`${stats.userCount} / ${stats.topicCount}`} />
</div>
<section className="rounded-xl border border-base-border bg-base-surface p-4 shadow-card">
<h2 className="mb-4 text-sm font-semibold text-slate-200">Notifications, last 14 days</h2>
<DailyBarChart data={stats.notificationsByDay} />
</section>
<div className="grid gap-4 md:grid-cols-2">
<section className="rounded-xl border border-base-border bg-base-surface p-4 shadow-card">
<h2 className="mb-3 text-sm font-semibold text-slate-200">Push delivery (24h)</h2>
{stats.pushAttemptsLast24h === 0 ? (
<p className="text-sm text-slate-500">No push attempts in the last 24 hours.</p>
) : (
<DonutChart
data={[
{
label: 'Delivered',
value: stats.pushAttemptsLast24h - stats.pushFailuresLast24h,
color: '#34d399',
},
{ label: 'Failed', value: stats.pushFailuresLast24h, color: '#f87171' },
]}
/>
)}
</section>
<section className="rounded-xl border border-base-border bg-base-surface p-4 shadow-card">
<h2 className="mb-3 text-sm font-semibold text-slate-200">Top topics share</h2>
{stats.topTopics.length === 0 ? (
<p className="text-sm text-slate-500">No notifications yet.</p>
) : (
<DonutChart
data={stats.topTopics.map((t) => ({
label: t.displayName,
value: t.total,
color: colorForLabel(t.slug).fg,
}))}
/>
)}
</section>
</div>
<div className="grid gap-4 md:grid-cols-2">
<section className="rounded-xl border border-base-border bg-base-surface p-4 shadow-card">
<h2 className="mb-3 text-sm font-semibold text-slate-200">Top topics</h2>
{stats.topTopics.length === 0 ? (
<p className="text-sm text-slate-500">No notifications yet.</p>
) : (
<ul className="space-y-2">
{stats.topTopics.map((t) => (
<li key={t.slug} className="flex items-center justify-between text-sm">
<span className="text-slate-300">{t.displayName}</span>
<span className="text-slate-500">{t.total.toLocaleString()}</span>
</li>
))}
</ul>
)}
</section>
<section className="rounded-xl border border-base-border bg-base-surface p-4 shadow-card">
<h2 className="mb-3 text-sm font-semibold text-slate-200">Noisiest sources (24h)</h2>
{stats.noisiestSources.length === 0 ? (
<p className="text-sm text-slate-500">No activity in the last 24 hours.</p>
) : (
<ul className="space-y-2">
{stats.noisiestSources.map((s) => (
<li key={s.source} className="flex items-center justify-between text-sm">
<span className="text-slate-300">{s.source}</span>
<span className={s.noisy ? 'font-medium text-amber-400' : 'text-slate-500'}>
{s.total.toLocaleString()}/day{s.noisy && ' ⚠'}
</span>
</li>
))}
</ul>
)}
</section>
</div>
<section className="rounded-xl border border-base-border bg-base-surface p-4 shadow-card">
<h2 className="mb-3 text-sm font-semibold text-slate-200">Recent events</h2>
{stats.recentAuditEvents.length === 0 ? (
<p className="text-sm text-slate-500">Nothing logged yet.</p>
) : (
<ul className="space-y-2">
{stats.recentAuditEvents.map((e) => (
<li key={e.id} className="flex items-center justify-between text-sm">
<span className="text-slate-300">{e.action}</span>
<span className="text-slate-500">{formatRelativeTime(e.createdAt)}</span>
</li>
))}
</ul>
)}
</section>
</div>
)
}
@@ -0,0 +1,29 @@
import { useAdminDevices } from '../../hooks/useAdmin'
import { formatRelativeTime } from '../../lib/utils'
import { friendlyDeviceName } from '../../lib/userAgent'
export function DevicesAdminPage() {
const { data: devices } = useAdminDevices()
return (
<div className="space-y-2">
{devices?.length === 0 && <p className="text-sm text-slate-500">No devices registered yet.</p>}
{devices?.map((device) => (
<div
key={device.id}
className="flex flex-wrap items-center gap-3 rounded-xl border border-base-border bg-base-surface p-4 shadow-card"
>
<div className="min-w-40 flex-1">
<p className="text-sm font-medium text-slate-100">
{device.deviceLabel ?? friendlyDeviceName(device.userAgent)}
</p>
<p className="text-xs text-slate-500">
{device.userDisplayName} ({device.userEmail}) ·{' '}
{device.disabledAt ? 'disabled' : `last seen ${formatRelativeTime(device.lastSeenAt)}`}
</p>
</div>
</div>
))}
</div>
)
}
@@ -0,0 +1,43 @@
import { useAdminSources } from '../../hooks/useAdmin'
export function SourcesAdminPage() {
const { data: sources } = useAdminSources()
return (
<div className="overflow-x-auto rounded-xl border border-base-border bg-base-surface shadow-card">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-base-border text-xs uppercase tracking-wide text-slate-500">
<th className="px-4 py-3 font-medium">Source</th>
<th className="px-4 py-3 font-medium">Last 24h</th>
<th className="px-4 py-3 font-medium">Status</th>
</tr>
</thead>
<tbody>
{sources?.map((s) => (
<tr key={s.source} className="border-b border-base-border last:border-0">
<td className="px-4 py-3 font-medium text-slate-200">{s.source}</td>
<td className="px-4 py-3 text-slate-400">{s.last24h.toLocaleString()}</td>
<td className="px-4 py-3">
{s.noisy ? (
<span className="rounded bg-amber-500/15 px-1.5 py-0.5 text-xs font-medium text-amber-400">
Unusually noisy
</span>
) : (
<span className="text-xs text-slate-500">Normal</span>
)}
</td>
</tr>
))}
{sources?.length === 0 && (
<tr>
<td colSpan={3} className="px-4 py-6 text-center text-slate-500">
No publishing activity in the last 24 hours.
</td>
</tr>
)}
</tbody>
</table>
</div>
)
}
@@ -0,0 +1,184 @@
import { type FormEvent, useEffect, useState } from 'react'
import { Switch } from '../../components/ui/Switch'
import { useSystemSettings, useUpdateSystemSettings } from '../../hooks/useAdmin'
const PRIORITY_OPTIONS = [
{ label: 'Low (2) and above', value: 2 },
{ label: 'Default (3) and above', value: 3 },
{ label: 'High (4) and above', value: 4 },
{ label: 'Urgent (5) only', value: 5 },
]
export function SystemSettingsAdminPage() {
const { data: settings } = useSystemSettings()
const updateSettings = useUpdateSystemSettings()
const [retentionDefaultDays, setRetentionDefaultDays] = useState('')
const [vapidSubject, setVapidSubject] = useState('')
const [quietEnabled, setQuietEnabled] = useState(false)
const [quietStart, setQuietStart] = useState('23:00')
const [quietEnd, setQuietEnd] = useState('07:00')
const [quietTimezone, setQuietTimezone] = useState('UTC')
const [quietMinPriority, setQuietMinPriority] = useState(4)
const [formError, setFormError] = useState<string | null>(null)
const [saved, setSaved] = useState(false)
useEffect(() => {
if (!settings) return
setRetentionDefaultDays(String(settings.retentionDefaultDays))
setVapidSubject(settings.vapidSubject)
setQuietEnabled(settings.quietHours.enabled)
setQuietStart(settings.quietHours.start)
setQuietEnd(settings.quietHours.end)
setQuietTimezone(settings.quietHours.timezone)
setQuietMinPriority(settings.quietHours.minPriority)
}, [settings])
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
setFormError(null)
setSaved(false)
updateSettings.mutate(
{
retentionDefaultDays: Number(retentionDefaultDays),
vapidSubject: vapidSubject.trim(),
quietHours: {
enabled: quietEnabled,
start: quietStart,
end: quietEnd,
timezone: quietTimezone.trim() || 'UTC',
minPriority: quietMinPriority,
},
},
{
onSuccess: () => setSaved(true),
onError: (err) => setFormError(err instanceof Error ? err.message : 'Failed to save settings'),
},
)
}
return (
<form onSubmit={handleSubmit} className="max-w-lg space-y-6">
<div className="space-y-4 rounded-xl border border-base-border bg-base-surface p-4 shadow-card">
<div className="space-y-1">
<label className="text-xs font-medium text-slate-400">Default retention (days)</label>
<input
required
type="number"
min={0}
value={retentionDefaultDays}
onChange={(e) => setRetentionDefaultDays(e.target.value)}
className="w-full rounded-lg border border-base-border bg-base-raised px-3 py-2 text-sm text-slate-100 focus:border-accent focus:outline-none"
/>
<p className="text-xs text-slate-600">
Applied to any topic that doesn't set its own retention (Admin → Topics). 0 means forever.
Picked up by the hourly cleanup job — no restart needed.
</p>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-slate-400">VAPID subject</label>
<input
required
value={vapidSubject}
onChange={(e) => setVapidSubject(e.target.value)}
placeholder="mailto:you@example.com"
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"
/>
<p className="text-xs text-slate-600">
A <code className="rounded bg-base-raised px-1 py-0.5">mailto:</code> or{' '}
<code className="rounded bg-base-raised px-1 py-0.5">https:</code> contact identifying this
server to push services — Apple's push service specifically rejects VAPID JWTs whose
subject isn't one of those. Takes effect on the next push, no restart needed.
</p>
</div>
</div>
<div className="space-y-4 rounded-xl border border-base-border bg-base-surface p-4 shadow-card">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-semibold text-slate-200">Quiet hours</h2>
<p className="mt-0.5 text-xs text-slate-500">
Household-wide — during this window, only high-priority notifications still push.
</p>
</div>
<Switch checked={quietEnabled} onChange={setQuietEnabled} label="Enable quiet hours" />
</div>
{quietEnabled && (
<>
<div className="flex items-end gap-3">
<div className="flex-1 space-y-1">
<label className="text-xs font-medium text-slate-400">Start</label>
<input
type="time"
required
value={quietStart}
onChange={(e) => setQuietStart(e.target.value)}
className="w-full rounded-lg border border-base-border bg-base-raised px-3 py-2 text-sm text-slate-100 focus:border-accent focus:outline-none"
/>
</div>
<div className="flex-1 space-y-1">
<label className="text-xs font-medium text-slate-400">End</label>
<input
type="time"
required
value={quietEnd}
onChange={(e) => setQuietEnd(e.target.value)}
className="w-full rounded-lg border border-base-border bg-base-raised px-3 py-2 text-sm text-slate-100 focus:border-accent focus:outline-none"
/>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-slate-400">Timezone</label>
<input
required
value={quietTimezone}
onChange={(e) => setQuietTimezone(e.target.value)}
placeholder="Europe/Stockholm"
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"
/>
<p className="text-xs text-slate-600">
IANA timezone name (e.g. <code className="rounded bg-base-raised px-1 py-0.5">Europe/Stockholm</code>).
The server itself typically runs in UTC, so this is what makes "23:00" mean your
actual bedtime.
</p>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-slate-400">Still push during quiet hours</label>
<select
value={quietMinPriority}
onChange={(e) => setQuietMinPriority(Number(e.target.value))}
className="w-full rounded-lg border border-base-border bg-base-raised px-3 py-2 text-sm text-slate-200 focus:border-accent focus:outline-none"
>
{PRIORITY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<p className="text-xs text-slate-600">
Anything below this still gets stored and shown in-app — it just won't buzz anyone's
phone during the window.
</p>
</div>
</>
)}
</div>
<div className="flex items-center gap-3">
<button
type="submit"
disabled={updateSettings.isPending}
className="rounded-lg bg-accent px-3.5 py-2 text-sm font-semibold text-white transition-colors hover:bg-accent-dim disabled:opacity-50"
>
Save
</button>
{saved && !formError && <p className="text-xs text-emerald-400">Saved.</p>}
{formError && <p className="text-xs text-red-400">{formError}</p>}
</div>
</form>
)
}
@@ -0,0 +1,183 @@
import { Copy, Plus, RotateCw, ShieldOff, Trash2, X } from 'lucide-react'
import { type FormEvent, useState } from 'react'
import {
useAdminTokens,
useCreateToken,
useDeleteToken,
useRevokeToken,
useRotateToken,
} from '../../hooks/useAdmin'
import { formatRelativeTime } from '../../lib/utils'
function PlaintextBanner({ plaintext, onDismiss }: { plaintext: string; onDismiss: () => void }) {
return (
<div className="rounded-xl border border-accent/30 bg-accent/10 p-4 shadow-card">
<p className="mb-2 text-sm font-medium text-accent-bright">
Copy this token now — it won't be shown again.
</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded-lg bg-base-bg px-3 py-2 text-xs text-slate-200">
{plaintext}
</code>
<button
type="button"
onClick={() => navigator.clipboard.writeText(plaintext)}
className="rounded-lg border border-base-border bg-base-surface p-2 text-slate-400 transition-colors hover:text-slate-200"
>
<Copy className="h-4 w-4" />
</button>
</div>
<button
type="button"
onClick={onDismiss}
className="mt-2 text-xs font-medium text-slate-400 hover:text-slate-200"
>
Done, dismiss
</button>
</div>
)
}
export function TokensAdminPage() {
const { data: tokens } = useAdminTokens()
const createToken = useCreateToken()
const revokeToken = useRevokeToken()
const rotateToken = useRotateToken()
const deleteToken = useDeleteToken()
const [name, setName] = useState('')
const [newPlaintext, setNewPlaintext] = useState<string | null>(null)
const handleCreate = (e: FormEvent) => {
e.preventDefault()
createToken.mutate(
{ name },
{
onSuccess: (result) => {
setNewPlaintext(result.plaintext)
setName('')
},
},
)
}
const revokedTokens = tokens?.filter((t) => t.revokedAt) ?? []
const handleDeleteAllRevoked = () => {
if (
!window.confirm(
`Permanently delete ${revokedTokens.length} revoked token${revokedTokens.length === 1 ? '' : 's'}? This can't be undone.`,
)
) {
return
}
for (const t of revokedTokens) deleteToken.mutate(t.id)
}
return (
<div className="space-y-6">
{newPlaintext && (
<PlaintextBanner plaintext={newPlaintext} onDismiss={() => setNewPlaintext(null)} />
)}
{revokedTokens.length > 0 && (
<div className="flex items-center justify-between rounded-xl border border-base-border bg-base-surface p-4 shadow-card">
<p className="text-sm text-slate-400">
{revokedTokens.length} revoked token{revokedTokens.length === 1 ? '' : 's'} cluttering
this list.
</p>
<button
type="button"
disabled={deleteToken.isPending}
onClick={handleDeleteAllRevoked}
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 disabled:opacity-50"
>
<X className="h-3.5 w-3.5" />
Delete all revoked
</button>
</div>
)}
<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-48 space-y-1">
<label className="text-xs font-medium text-slate-400">Name</label>
<input
required
value={name}
onChange={(e) => setName(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={createToken.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 token
</button>
</form>
<div className="space-y-2">
{tokens?.map((token) => (
<div
key={token.id}
className="flex flex-wrap items-center gap-3 rounded-xl border border-base-border bg-base-surface p-4 shadow-card"
>
<div className="min-w-40 flex-1">
<p className="text-sm font-medium text-slate-100">{token.name}</p>
<p className="text-xs text-slate-500">
{token.tokenPrefix}… ·{' '}
{token.revokedAt
? 'revoked'
: token.lastUsedAt
? `last used ${formatRelativeTime(token.lastUsedAt)}`
: 'never used'}{' '}
· {token.usageCount.toLocaleString()} uses
</p>
</div>
<button
type="button"
disabled={rotateToken.isPending}
onClick={() =>
rotateToken.mutate(token.id, {
onSuccess: (result) => setNewPlaintext(result.plaintext),
})
}
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"
>
<RotateCw className="h-3.5 w-3.5" />
Rotate
</button>
<button
type="button"
disabled={revokeToken.isPending || Boolean(token.revokedAt)}
onClick={() => revokeToken.mutate(token.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 disabled:opacity-40"
>
<ShieldOff className="h-3.5 w-3.5" />
Revoke
</button>
<button
type="button"
disabled={deleteToken.isPending || !token.revokedAt}
title={token.revokedAt ? undefined : 'Revoke this token before deleting it'}
onClick={() => {
if (window.confirm(`Permanently delete "${token.name}"? This can't be undone.`)) {
deleteToken.mutate(token.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 disabled:opacity-40"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</button>
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,54 @@
import { ArrowLeft } from 'lucide-react'
import { Link, useParams } from 'react-router-dom'
import { DailyBarChart } from '../../components/admin/DailyBarChart'
import { useTopicStats } from '../../hooks/useAdmin'
import { useTopic } from '../../hooks/useTopics'
export function TopicStatsAdminPage() {
const { slug } = useParams<{ slug: string }>()
const { data: topic } = useTopic(slug)
const { data: stats, isLoading } = useTopicStats(slug)
return (
<div className="space-y-6">
<Link
to="/admin/topics"
className="inline-flex items-center gap-1.5 text-xs text-slate-500 transition-colors hover:text-slate-300"
>
<ArrowLeft className="h-3.5 w-3.5" />
Back to topics
</Link>
<h1 className="text-lg font-semibold tracking-tight text-slate-100">
{topic?.displayName ?? slug} — stats
</h1>
{isLoading || !stats ? (
<p className="text-sm text-slate-500">Loading…</p>
) : (
<>
<section className="rounded-xl border border-base-border bg-base-surface p-4 shadow-card">
<h2 className="mb-4 text-sm font-semibold text-slate-200">Notifications, last 14 days</h2>
<DailyBarChart data={stats.dailyCounts} />
</section>
<section className="rounded-xl border border-base-border bg-base-surface p-4 shadow-card">
<h2 className="mb-3 text-sm font-semibold text-slate-200">Most common notifications</h2>
{stats.topMessages.length === 0 ? (
<p className="text-sm text-slate-500">No notifications in this topic yet.</p>
) : (
<ul className="space-y-2">
{stats.topMessages.map((m) => (
<li key={m.message} className="flex items-center justify-between gap-4 text-sm">
<span className="truncate text-slate-300">{m.message}</span>
<span className="shrink-0 text-slate-500">{m.total.toLocaleString()}</span>
</li>
))}
</ul>
)}
</section>
</>
)}
</div>
)
}
@@ -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>
)
}
@@ -0,0 +1,37 @@
import { useSession } from '../../hooks/useSession'
import { useAdminUsers, useSetUserRole } from '../../hooks/useAdmin'
import { formatRelativeTime } from '../../lib/utils'
export function UsersAdminPage() {
const { data: users } = useAdminUsers()
const { data: session } = useSession()
const setRole = useSetUserRole()
return (
<div className="space-y-2">
{users?.map((user) => (
<div
key={user.id}
className="flex flex-wrap items-center gap-3 rounded-xl border border-base-border bg-base-surface p-4 shadow-card"
>
<div className="min-w-40 flex-1">
<p className="text-sm font-medium text-slate-100">{user.displayName}</p>
<p className="text-xs text-slate-500">
{user.email} ·{' '}
{user.lastLoginAt ? `last seen ${formatRelativeTime(user.lastLoginAt)}` : 'never logged in'}
</p>
</div>
<select
value={user.role}
disabled={user.id === session?.user?.id || setRole.isPending}
onChange={(e) => setRole.mutate({ id: user.id, role: e.target.value as 'admin' | 'user' })}
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 disabled:opacity-50"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
))}
</div>
)
}
+75
View File
@@ -0,0 +1,75 @@
/// <reference lib="webworker" />
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
import { registerRoute } from 'workbox-routing'
import { NetworkFirst } from 'workbox-strategies'
declare const self: ServiceWorkerGlobalScope & {
__WB_MANIFEST: Array<{ url: string; revision: string | null }>
}
// App-shell precache, injected at build time by vite-plugin-pwa.
precacheAndRoute(self.__WB_MANIFEST)
cleanupOutdatedCaches()
// Short-lived cache so a flaky connection still shows the last-known
// notification list instead of a blank screen — never for mutating
// requests, and never for the SSE stream (a NetworkFirst strategy awaits
// the full response body, which for an open event-stream never resolves).
registerRoute(
({ url, request }) =>
request.method === 'GET' &&
url.pathname.startsWith('/api/') &&
url.pathname !== '/api/v1/stream',
new NetworkFirst({ cacheName: 'notizarr-api', networkTimeoutSeconds: 5 }),
)
interface PushPayload {
notificationId: string
topicSlug: string
title: string
body: string
icon?: string
}
self.addEventListener('push', (event) => {
if (!event.data) return
const payload = event.data.json() as PushPayload
const deepLink = `/topics/${payload.topicSlug}/messages/${payload.notificationId}`
event.waitUntil(
self.registration.showNotification(payload.title, {
body: payload.body,
icon: payload.icon ?? '/icons/icon-192.png',
badge: '/icons/icon-192.png',
tag: payload.notificationId,
data: { deepLink },
}),
)
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
const deepLink = (event.notification.data as { deepLink?: string } | undefined)?.deepLink ?? '/'
event.waitUntil(
(async () => {
const allClients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true })
for (const client of allClients) {
if ('focus' in client) {
await client.focus()
client.postMessage({ type: 'navigate', path: deepLink })
return
}
}
await self.clients.openWindow(deepLink)
})(),
)
})
self.addEventListener('install', () => {
void self.skipWaiting()
})
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim())
})
+47
View File
@@ -0,0 +1,47 @@
export interface SessionUser {
id: string
email: string
displayName: string
role: 'admin' | 'user'
}
export interface SessionResponse {
user: SessionUser | null
authMode: 'oidc' | 'proxy' | 'disabled'
}
export interface Topic {
id: string
slug: string
displayName: string
description: string | null
icon: string | null
accentColor: string | null
retentionDays: number | null
restricted: boolean
totalCount: number
unreadCount: number
latestAt: string | null
pushEnabled: boolean
mutedUntil: string | null
createdAt: string
updatedAt: string
}
export interface Notification {
id: string
topicId: string
topicSlug: string
topicDisplayName: string
source: string
title: string
message: string
externalUrl: string | null
imageUrl: string | null
occurrenceCount: number
rawPriority: string | null
read: boolean
starred: boolean
createdAt: string
updatedAt: string
}
+59
View File
@@ -0,0 +1,59 @@
import type { Config } from 'tailwindcss'
// Notizarr's visual identity: dark-first, quiet neutrals, one confident
// accent. Deliberately not shadcn's default palette — see
// docs/phase1/01-architecture.md's UI quality requirements.
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
darkMode: 'class',
theme: {
extend: {
colors: {
base: {
bg: '#0a0b0e',
surface: '#131620',
raised: '#181c28',
border: '#232838',
hover: '#1b1f2c',
},
accent: {
DEFAULT: '#818cf8',
dim: '#6366f1',
bright: '#a5b4fc',
},
},
fontFamily: {
sans: [
'ui-sans-serif',
'system-ui',
'-apple-system',
'Segoe UI',
'Roboto',
'Helvetica Neue',
'Arial',
'sans-serif',
],
},
boxShadow: {
card: '0 1px 2px 0 rgb(0 0 0 / 0.4)',
raised: '0 4px 16px -4px rgb(0 0 0 / 0.5)',
},
borderRadius: {
xl: '0.875rem',
'2xl': '1.125rem',
},
animation: {
'fade-in': 'fade-in 0.2s ease-out',
'slide-up': 'slide-up 0.25s cubic-bezier(0.16, 1, 0.3, 1)',
},
keyframes: {
'fade-in': { from: { opacity: '0' }, to: { opacity: '1' } },
'slide-up': {
from: { opacity: '0', transform: 'translateY(8px)' },
to: { opacity: '1', transform: 'translateY(0)' },
},
},
},
},
plugins: [],
} satisfies Config
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src"],
"exclude": ["src/sw.ts"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "WebWorker"],
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true
},
"include": ["src/sw.ts"]
}
+47
View File
@@ -0,0 +1,47 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
react(),
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
injectRegister: 'auto',
manifest: {
name: 'Notizarr',
short_name: 'Notizarr',
description: 'Self-hosted notification hub',
start_url: '/?source=pwa',
scope: '/',
display: 'standalone',
background_color: '#0a0b0e',
theme_color: '#0a0b0e',
orientation: 'portrait',
categories: ['utilities'],
icons: [
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
{
src: '/icons/maskable-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
},
injectManifest: {
globPatterns: ['**/*.{js,css,html,svg,png}'],
},
devOptions: {
enabled: false,
},
}),
],
build: {
outDir: 'dist',
emptyOutDir: true,
},
})