mirror of
https://github.com/kennethnym/aris.git
synced 2026-04-07 10:31:18 +01:00
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:
201
apps/admin-dashboard/src/routes/_dashboard.tsx
Normal file
201
apps/admin-dashboard/src/routes/_dashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user