feat: add admin dashboard app (#91)

* feat: add admin dashboard app

- React + Vite + TanStack Router + TanStack Query
- Auth with better-auth (login, session, admin guard)
- Source config management (WeatherKit credentials, user config)
- Feed query panel
- Location push card
- General settings with health check
- CORS middleware for cross-origin auth
- Disable CSRF check in dev mode
- Sonner toasts for mutation feedback

Co-authored-by: Ona <no-reply@ona.com>

* fix: use useQuery instead of getQueryData

Co-authored-by: Ona <no-reply@ona.com>

* refactor: remove backend changes from dashboard PR

Backend CORS/CSRF changes moved to #92.
Source registry removed (sources hardcoded in frontend).

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
2026-03-23 00:31:34 +00:00
committed by GitHub
parent 35c6371d48
commit 9b0ac1cd4e
51 changed files with 4586 additions and 132 deletions

View File

@@ -0,0 +1,13 @@
import { createRootRouteWithContext, Outlet } from "@tanstack/react-router"
import type { QueryClient } from "@tanstack/react-query"
import { TooltipProvider } from "@/components/ui/tooltip"
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
component: function RootLayout() {
return (
<TooltipProvider>
<Outlet />
</TooltipProvider>
)
},
})

View File

@@ -0,0 +1,201 @@
import { createRoute, Outlet, redirect, useMatchRoute, useNavigate, Link } from "@tanstack/react-router"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import {
Calendar,
CalendarDays,
CircleDot,
CloudSun,
Loader2,
LogOut,
MapPin,
Rss,
Server,
TriangleAlert,
} from "lucide-react"
import { fetchConfigs, fetchSources } from "@/lib/api"
import { getSession, signOut } from "@/lib/auth"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar"
import { Route as rootRoute } from "./__root"
const SOURCE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
"aelis.location": MapPin,
"aelis.weather": CloudSun,
"aelis.caldav": CalendarDays,
"aelis.google-calendar": Calendar,
}
export const Route = createRoute({
getParentRoute: () => rootRoute,
id: "dashboard",
beforeLoad: async ({ context }) => {
const session = await context.queryClient.ensureQueryData({
queryKey: ["session"],
queryFn: getSession,
})
if (!session?.user) {
throw redirect({ to: "/login" })
}
return { user: session.user }
},
component: DashboardLayout,
pendingComponent: () => (
<div className="flex min-h-svh items-center justify-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
),
})
function DashboardLayout() {
const { user } = Route.useRouteContext()
const navigate = useNavigate()
const queryClient = useQueryClient()
const matchRoute = useMatchRoute()
const { data: sources = [] } = useQuery({
queryKey: ["sources"],
queryFn: fetchSources,
})
const {
data: configs = [],
error: configsError,
refetch: refetchConfigs,
} = useQuery({
queryKey: ["configs"],
queryFn: fetchConfigs,
})
const logoutMutation = useMutation({
mutationFn: signOut,
onSuccess() {
queryClient.setQueryData(["session"], null)
queryClient.clear()
navigate({ to: "/login" })
},
})
const error = configsError?.message ?? null
const configMap = new Map(configs.map((c) => [c.sourceId, c]))
return (
<SidebarProvider>
<Sidebar>
<SidebarHeader>
<div className="flex items-center justify-between px-2 py-1">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{user.name}</p>
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
</div>
<Button variant="ghost" size="icon" className="size-7 shrink-0" onClick={() => logoutMutation.mutate()}>
<LogOut className="size-3.5" />
</Button>
</div>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>General</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
isActive={!!matchRoute({ to: "/" })}
asChild
>
<Link to="/">
<Server className="size-4" />
<span>Server</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={!!matchRoute({ to: "/feed" })}
asChild
>
<Link to="/feed">
<Rss className="size-4" />
<span>Feed</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Sources</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{sources.map((source) => {
const Icon = SOURCE_ICONS[source.id] ?? CircleDot
const cfg = configMap.get(source.id)
const isEnabled = source.alwaysEnabled || cfg?.enabled
return (
<SidebarMenuItem key={source.id}>
<SidebarMenuButton
isActive={!!matchRoute({ to: "/sources/$sourceId", params: { sourceId: source.id } })}
asChild
>
<Link to="/sources/$sourceId" params={{ sourceId: source.id }}>
<Icon className="size-4" />
<span>{source.name}</span>
</Link>
</SidebarMenuButton>
{isEnabled && (
<SidebarMenuBadge>
<CircleDot className="size-2.5 text-primary" />
</SidebarMenuBadge>
)}
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<SidebarInset>
<header className="flex h-12 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 !h-4" />
</header>
<main className="flex-1 p-6">
{error && (
<Alert variant="destructive" className="mb-6">
<TriangleAlert className="size-4" />
<AlertDescription className="flex items-center justify-between">
<span>{error}</span>
<Button variant="ghost" size="sm" onClick={() => refetchConfigs()}>
Retry
</Button>
</AlertDescription>
</Alert>
)}
<Outlet />
</main>
</SidebarInset>
</SidebarProvider>
)
}

View File

@@ -0,0 +1,10 @@
import { createRoute } from "@tanstack/react-router"
import { FeedPanel } from "@/components/feed-panel"
import { Route as dashboardRoute } from "../_dashboard"
export const Route = createRoute({
getParentRoute: () => dashboardRoute,
path: "/feed",
component: FeedPanel,
})

View File

@@ -0,0 +1,10 @@
import { createRoute } from "@tanstack/react-router"
import { GeneralSettingsPanel } from "@/components/general-settings-panel"
import { Route as dashboardRoute } from "../_dashboard"
export const Route = createRoute({
getParentRoute: () => dashboardRoute,
path: "/",
component: GeneralSettingsPanel,
})

View File

@@ -0,0 +1,34 @@
import { createRoute } from "@tanstack/react-router"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { fetchSources } from "@/lib/api"
import { SourceConfigPanel } from "@/components/source-config-panel"
import { Route as dashboardRoute } from "../_dashboard"
export const Route = createRoute({
getParentRoute: () => dashboardRoute,
path: "/sources/$sourceId",
component: SourceRoute,
})
function SourceRoute() {
const { sourceId } = Route.useParams()
const queryClient = useQueryClient()
const { data: sources = [] } = useQuery({
queryKey: ["sources"],
queryFn: fetchSources,
})
const source = sources.find((s) => s.id === sourceId)
if (!source) {
return <p className="text-sm text-muted-foreground">Source not found.</p>
}
return (
<SourceConfigPanel
key={source.id}
source={source}
onUpdate={() => queryClient.invalidateQueries({ queryKey: ["configs"] })}
/>
)
}

View File

@@ -0,0 +1,22 @@
import { createRoute, useNavigate } from "@tanstack/react-router"
import { useQueryClient } from "@tanstack/react-query"
import type { AuthSession } from "@/lib/auth"
import { LoginPage } from "@/components/login-page"
import { Route as rootRoute } from "./__root"
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/login",
component: function LoginRoute() {
const navigate = useNavigate()
const queryClient = useQueryClient()
function handleLogin(session: AuthSession) {
queryClient.setQueryData(["session"], session)
navigate({ to: "/" })
}
return <LoginPage onLogin={handleLogin} />
},
})