mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-30 14:51:17 +01:00
209 lines
6.4 KiB
TypeScript
209 lines
6.4 KiB
TypeScript
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,
|
|
TrainFront,
|
|
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,
|
|
"aelis.tfl": TrainFront,
|
|
}
|
|
|
|
export const Route = createRoute({
|
|
getParentRoute: () => rootRoute,
|
|
id: "dashboard",
|
|
beforeLoad: async ({ context }) => {
|
|
let session: Awaited<ReturnType<typeof getSession>> | null = null
|
|
try {
|
|
session = await context.queryClient.ensureQueryData({
|
|
queryKey: ["session"],
|
|
queryFn: getSession,
|
|
})
|
|
} catch {
|
|
throw redirect({ to: "/login" })
|
|
}
|
|
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>
|
|
)
|
|
}
|