Initial commit
No files matched your search
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -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 |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -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)
|
||||
})
|
||||
@@ -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 |
@@ -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 |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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('/')
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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%)`,
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "WebWorker"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/sw.ts"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||