img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/web/src/components/ui/dialog.tsx b/web/src/components/ui/dialog.tsx
new file mode 100644
index 00000000..23239d9a
--- /dev/null
+++ b/web/src/components/ui/dialog.tsx
@@ -0,0 +1,157 @@
+import * as React from "react"
+import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Dialog({ ...props }: DialogPrimitive.Root.Props) {
+ return
+}
+
+function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
+ return
+}
+
+function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
+ return
+}
+
+function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: DialogPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: DialogPrimitive.Popup.Props & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+ }
+ >
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+ }>
+ Close
+
+ )}
+
+ )
+}
+
+function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: DialogPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/web/src/components/ui/dropdown-menu.tsx b/web/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 00000000..30ddda7b
--- /dev/null
+++ b/web/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,270 @@
+import * as React from "react"
+import { Menu as MenuPrimitive } from "@base-ui/react/menu"
+
+import { cn } from "@/lib/utils"
+import { ChevronRightIcon, CheckIcon } from "lucide-react"
+
+function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
+ return
+}
+
+function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
+ return
+}
+
+function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
+ return
+}
+
+function DropdownMenuContent({
+ align = "start",
+ alignOffset = 0,
+ side = "bottom",
+ sideOffset = 4,
+ className,
+ ...props
+}: MenuPrimitive.Popup.Props &
+ Pick<
+ MenuPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+
+
+ )
+}
+
+function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
+ return
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: MenuPrimitive.GroupLabel.Props & {
+ inset?: boolean
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: MenuPrimitive.Item.Props & {
+ inset?: boolean
+ variant?: "default" | "destructive"
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
+ return
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: MenuPrimitive.SubmenuTrigger.Props & {
+ inset?: boolean
+}) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function DropdownMenuSubContent({
+ align = "start",
+ alignOffset = -3,
+ side = "right",
+ sideOffset = 0,
+ className,
+ ...props
+}: React.ComponentProps
) {
+ return (
+
+ )
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ inset,
+ ...props
+}: MenuPrimitive.CheckboxItem.Props & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
+ return (
+
+ )
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ inset,
+ ...props
+}: MenuPrimitive.RadioItem.Props & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: MenuPrimitive.Separator.Props) {
+ return (
+
+ )
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+}
diff --git a/web/src/components/ui/input.tsx b/web/src/components/ui/input.tsx
new file mode 100644
index 00000000..7d21babb
--- /dev/null
+++ b/web/src/components/ui/input.tsx
@@ -0,0 +1,20 @@
+import * as React from "react"
+import { Input as InputPrimitive } from "@base-ui/react/input"
+
+import { cn } from "@/lib/utils"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/web/src/components/ui/label.tsx b/web/src/components/ui/label.tsx
new file mode 100644
index 00000000..f1629961
--- /dev/null
+++ b/web/src/components/ui/label.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Label({ className, ...props }: React.ComponentProps<"label">) {
+ return (
+
+ )
+}
+
+export { Label }
diff --git a/web/src/components/ui/popover.tsx b/web/src/components/ui/popover.tsx
new file mode 100644
index 00000000..1428fddf
--- /dev/null
+++ b/web/src/components/ui/popover.tsx
@@ -0,0 +1,88 @@
+import * as React from "react"
+import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
+
+import { cn } from "@/lib/utils"
+
+function Popover({ ...props }: PopoverPrimitive.Root.Props) {
+ return
+}
+
+function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
+ return
+}
+
+function PopoverContent({
+ className,
+ align = "center",
+ alignOffset = 0,
+ side = "bottom",
+ sideOffset = 4,
+ ...props
+}: PopoverPrimitive.Popup.Props &
+ Pick<
+ PopoverPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+
+
+ )
+}
+
+function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function PopoverDescription({
+ className,
+ ...props
+}: PopoverPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Popover,
+ PopoverContent,
+ PopoverDescription,
+ PopoverHeader,
+ PopoverTitle,
+ PopoverTrigger,
+}
diff --git a/web/src/components/ui/scroll-area.tsx b/web/src/components/ui/scroll-area.tsx
new file mode 100644
index 00000000..7668f217
--- /dev/null
+++ b/web/src/components/ui/scroll-area.tsx
@@ -0,0 +1,52 @@
+import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
+
+import { cn } from "@/lib/utils"
+
+function ScrollArea({
+ className,
+ children,
+ ...props
+}: ScrollAreaPrimitive.Root.Props) {
+ return (
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function ScrollBar({
+ className,
+ orientation = "vertical",
+ ...props
+}: ScrollAreaPrimitive.Scrollbar.Props) {
+ return (
+
+
+
+ )
+}
+
+export { ScrollArea, ScrollBar }
diff --git a/web/src/components/ui/select.tsx b/web/src/components/ui/select.tsx
new file mode 100644
index 00000000..a6d77862
--- /dev/null
+++ b/web/src/components/ui/select.tsx
@@ -0,0 +1,200 @@
+import * as React from "react"
+import { Select as SelectPrimitive } from "@base-ui/react/select"
+
+import { cn } from "@/lib/utils"
+import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
+
+const Select = SelectPrimitive.Root
+
+function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
+ return (
+
+ )
+}
+
+function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
+ return (
+
+ )
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: SelectPrimitive.Trigger.Props & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+ {children}
+
+ }
+ />
+
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ side = "bottom",
+ sideOffset = 4,
+ align = "center",
+ alignOffset = 0,
+ alignItemWithTrigger = true,
+ ...props
+}: SelectPrimitive.Popup.Props &
+ Pick<
+ SelectPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
+ >) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: SelectPrimitive.GroupLabel.Props) {
+ return (
+
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: SelectPrimitive.Item.Props) {
+ return (
+
+
+ {children}
+
+
+ }
+ >
+
+
+
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: SelectPrimitive.Separator.Props) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/web/src/components/ui/separator.tsx b/web/src/components/ui/separator.tsx
new file mode 100644
index 00000000..4f65961b
--- /dev/null
+++ b/web/src/components/ui/separator.tsx
@@ -0,0 +1,23 @@
+import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
+
+import { cn } from "@/lib/utils"
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ ...props
+}: SeparatorPrimitive.Props) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/web/src/components/ui/skeleton.tsx b/web/src/components/ui/skeleton.tsx
new file mode 100644
index 00000000..0118624f
--- /dev/null
+++ b/web/src/components/ui/skeleton.tsx
@@ -0,0 +1,13 @@
+import { cn } from "@/lib/utils"
+
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Skeleton }
diff --git a/web/src/components/ui/sonner.tsx b/web/src/components/ui/sonner.tsx
new file mode 100644
index 00000000..e510f14e
--- /dev/null
+++ b/web/src/components/ui/sonner.tsx
@@ -0,0 +1,48 @@
+import { Toaster as Sonner, type ToasterProps } from "sonner"
+import {
+ CircleCheckIcon,
+ InfoIcon,
+ TriangleAlertIcon,
+ OctagonXIcon,
+ Loader2Icon,
+} from "lucide-react"
+
+// Sourced from the app's own ThemeProvider rather than next-themes, which the
+// shadcn template assumes but this project does not use.
+import { useTheme } from "@/components/theme-provider"
+
+// React.CSSProperties has no room for custom properties, so widen it rather
+// than asserting the whole object.
+const toasterStyle: React.CSSProperties & Record<`--${string}`, string> = {
+ "--normal-bg": "var(--popover)",
+ "--normal-text": "var(--popover-foreground)",
+ "--normal-border": "var(--border)",
+ "--border-radius": "var(--radius)",
+}
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ const { theme } = useTheme()
+
+ return (
+ ,
+ info: ,
+ warning: ,
+ error: ,
+ loading: ,
+ }}
+ style={toasterStyle}
+ toastOptions={{
+ classNames: {
+ toast: "cn-toast",
+ },
+ }}
+ {...props}
+ />
+ )
+}
+
+export { Toaster }
diff --git a/web/src/components/ui/tabs.tsx b/web/src/components/ui/tabs.tsx
new file mode 100644
index 00000000..2adaeb6c
--- /dev/null
+++ b/web/src/components/ui/tabs.tsx
@@ -0,0 +1,80 @@
+import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+function Tabs({
+ className,
+ orientation = "horizontal",
+ ...props
+}: TabsPrimitive.Root.Props) {
+ return (
+
+ )
+}
+
+const tabsListVariants = cva(
+ "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
+ {
+ variants: {
+ variant: {
+ default: "bg-muted",
+ line: "gap-1 bg-transparent",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function TabsList({
+ className,
+ variant = "default",
+ ...props
+}: TabsPrimitive.List.Props & VariantProps) {
+ return (
+
+ )
+}
+
+function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
+ return (
+
+ )
+}
+
+function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
+ return (
+
+ )
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
diff --git a/web/src/components/ui/textarea.tsx b/web/src/components/ui/textarea.tsx
new file mode 100644
index 00000000..04d27f7d
--- /dev/null
+++ b/web/src/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/web/src/components/ui/tooltip.tsx b/web/src/components/ui/tooltip.tsx
new file mode 100644
index 00000000..96a9ec25
--- /dev/null
+++ b/web/src/components/ui/tooltip.tsx
@@ -0,0 +1,64 @@
+import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
+
+import { cn } from "@/lib/utils"
+
+function TooltipProvider({
+ delay = 0,
+ ...props
+}: TooltipPrimitive.Provider.Props) {
+ return (
+
+ )
+}
+
+function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
+ return
+}
+
+function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
+ return
+}
+
+function TooltipContent({
+ className,
+ side = "top",
+ sideOffset = 4,
+ align = "center",
+ alignOffset = 0,
+ children,
+ ...props
+}: TooltipPrimitive.Popup.Props &
+ Pick<
+ TooltipPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
From 4fdd78e8e77a51030bf2ebbf4c841eb4544c04cc Mon Sep 17 00:00:00 2001
From: Yerassyl Auyeskhan <95128018+ieraasyl@users.noreply.github.com>
Date: Sun, 26 Jul 2026 02:27:07 +0500
Subject: [PATCH 05/56] feat(web): add auth guard, session layer, and shared
query conventions
---
infra/docker-compose.yml | 6 +-
infra/nginx/nginx.dev.conf | 5 +-
web/.gitignore | 3 +
web/Dockerfile.dev | 5 +
web/src/api/client.ts | 24 +++--
web/src/api/query-keys.ts | 72 +++++++++++++++
web/src/components/query-boundary.tsx | 128 ++++++++++++++++++++++++++
web/src/features/auth/api.ts | 42 +++++++++
web/src/features/auth/schema.ts | 49 ++++++++++
web/src/features/auth/use-session.ts | 53 +++++++++++
web/src/lib/datetime.ts | 105 +++++++++++++++++++++
web/src/routeTree.gen.ts | 47 +++++++++-
web/src/routes/_app.tsx | 31 +++++++
web/src/routes/_app/announcements.tsx | 29 ++++++
web/src/routes/index.tsx | 53 +++++++++--
15 files changed, 631 insertions(+), 21 deletions(-)
create mode 100644 web/src/api/query-keys.ts
create mode 100644 web/src/components/query-boundary.tsx
create mode 100644 web/src/features/auth/api.ts
create mode 100644 web/src/features/auth/schema.ts
create mode 100644 web/src/features/auth/use-session.ts
create mode 100644 web/src/lib/datetime.ts
create mode 100644 web/src/routes/_app.tsx
create mode 100644 web/src/routes/_app/announcements.tsx
diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml
index 05c41bf7..4c4fdcc2 100644
--- a/infra/docker-compose.yml
+++ b/infra/docker-compose.yml
@@ -42,7 +42,7 @@ services:
restart: always
ports:
- "80:80" # Existing frontend/
- - "8080:8080" # New web/ — remove once the cutover lands
+ - "8081:8081" # New web/ — remove once the cutover lands
volumes:
- ./nginx/nginx.dev.conf:/etc/nginx/nginx.conf
- ../frontend/dist:/var/www/my-app/dist # Make sure this folder exists in production builds
@@ -168,6 +168,10 @@ services:
volumes:
- ../web:/app # Mount source for HMR
- /app/node_modules # Anonymous volume keeps the image's deps
+ environment:
+ # The bind mount makes pnpm's deps-status check want to purge
+ # node_modules, which it refuses to do without a TTY.
+ - CI=true
networks:
- nuros
diff --git a/infra/nginx/nginx.dev.conf b/infra/nginx/nginx.dev.conf
index bf3bb71f..3dea1543 100644
--- a/infra/nginx/nginx.dev.conf
+++ b/infra/nginx/nginx.dev.conf
@@ -114,9 +114,12 @@ http {
# against a single backend. Same API/WS/GCS routing as :80; only the
# document root differs. Remove this block at cutover, when :80 starts
# serving web/ directly.
+ #
+ # 8081 rather than 8080: the latter is a common default and was already
+ # taken on the dev machine this was first brought up on.
# ------------------------------------------------------------------------
server {
- listen 8080;
+ listen 8081;
server_name localhost;
client_max_body_size 100m;
diff --git a/web/.gitignore b/web/.gitignore
index ca59ec87..4bd514db 100644
--- a/web/.gitignore
+++ b/web/.gitignore
@@ -25,3 +25,6 @@ dist-ssr
# Generated from the backend OpenAPI schema
openapi.json
+
+# pnpm store, created when the dev container installs into the bind mount
+.pnpm-store/
diff --git a/web/Dockerfile.dev b/web/Dockerfile.dev
index 0927fcdd..b0c18080 100644
--- a/web/Dockerfile.dev
+++ b/web/Dockerfile.dev
@@ -5,6 +5,11 @@ FROM node:24-alpine
RUN apk add --no-cache bash curl git
RUN corepack enable
+# Keep the content-addressable store outside /app. The source directory is
+# bind-mounted from the host, so a store under it would write ~300MB of package
+# files straight into the working tree.
+ENV npm_config_store_dir=/pnpm-store
+
WORKDIR /app
# Cache the dependency layer separately from source.
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
index 2ead5c72..63b720c5 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -38,22 +38,30 @@ export class ApiError extends Error {
}
}
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null
+}
+
function describe(detail: unknown): string {
if (typeof detail === "string") return detail
- if (detail && typeof detail === "object" && "detail" in detail) {
- const inner = (detail as { detail: unknown }).detail
+
+ if (isRecord(detail) && "detail" in detail) {
+ const inner = detail.detail
if (typeof inner === "string") return inner
- // 422 bodies are [{loc, msg, type}, ...].
+
+ // FastAPI validation errors are [{loc, msg, type}, ...].
if (Array.isArray(inner)) {
- return inner
+ const messages: unknown[] = inner
+ return messages
.map((issue) =>
- issue && typeof issue === "object" && "msg" in issue
- ? String((issue as { msg: unknown }).msg)
+ isRecord(issue) && typeof issue.msg === "string"
+ ? issue.msg
: JSON.stringify(issue)
)
.join("; ")
}
}
+
return JSON.stringify(detail)
}
@@ -69,7 +77,9 @@ export async function unwrap(
if (error !== undefined || !response.ok) {
throw new ApiError(response.status, error)
}
- // 204/205 responses legitimately carry no body.
+ // 204/205 carry no body by design, and callers of those endpoints type the
+ // result as void — so widening undefined to T here is intentional.
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
return data as T
}
diff --git a/web/src/api/query-keys.ts b/web/src/api/query-keys.ts
new file mode 100644
index 00000000..a356f83a
--- /dev/null
+++ b/web/src/api/query-keys.ts
@@ -0,0 +1,72 @@
+/**
+ * Every query key in the app is built here.
+ *
+ * The previous app had 88 inline `queryKey:` literals with no shared
+ * convention — ["campusCurrent","communities"] next to ["grade-terms"] next to
+ * ["sg-members","list"] — which made targeted invalidation guesswork. Keys are
+ * hierarchical: invalidating `qk.events.all()` clears every events query,
+ * including lists and details.
+ */
+export const qk = {
+ session: () => ["session"] as const,
+
+ events: {
+ all: () => ["events"] as const,
+ list: (filters: Record) =>
+ ["events", "list", filters] as const,
+ detail: (id: number) => ["events", "detail", id] as const,
+ },
+
+ communities: {
+ all: () => ["communities"] as const,
+ list: (filters: Record) =>
+ ["communities", "list", filters] as const,
+ detail: (id: number) => ["communities", "detail", id] as const,
+ },
+
+ announcements: {
+ all: () => ["announcements"] as const,
+ bundle: () => ["announcements", "bundle"] as const,
+ telegram: () => ["announcements", "telegram"] as const,
+ },
+
+ opportunities: {
+ all: () => ["opportunities"] as const,
+ list: (filters: Record) =>
+ ["opportunities", "list", filters] as const,
+ detail: (id: number) => ["opportunities", "detail", id] as const,
+ },
+
+ courses: {
+ all: () => ["courses"] as const,
+ catalog: (filters: Record) =>
+ ["courses", "catalog", filters] as const,
+ registered: () => ["courses", "registered"] as const,
+ schedule: () => ["courses", "schedule"] as const,
+ gradeTerms: () => ["courses", "grade-terms"] as const,
+ grades: (filters: Record) =>
+ ["courses", "grades", filters] as const,
+ degreeAudit: () => ["courses", "degree-audit"] as const,
+ planner: () => ["courses", "planner"] as const,
+ },
+
+ sgotinish: {
+ all: () => ["sgotinish"] as const,
+ tickets: (filters: Record) =>
+ ["sgotinish", "tickets", filters] as const,
+ ticket: (id: number) => ["sgotinish", "tickets", id] as const,
+ messages: (conversationId: number) =>
+ ["sgotinish", "messages", conversationId] as const,
+ members: () => ["sgotinish", "members"] as const,
+ departments: () => ["sgotinish", "departments"] as const,
+ },
+
+ notifications: {
+ all: () => ["notifications"] as const,
+ list: (filters: Record) =>
+ ["notifications", "list", filters] as const,
+ },
+
+ search: (keyword: string, storageName: string) =>
+ ["search", storageName, keyword] as const,
+} as const
diff --git a/web/src/components/query-boundary.tsx b/web/src/components/query-boundary.tsx
new file mode 100644
index 00000000..79048d5d
--- /dev/null
+++ b/web/src/components/query-boundary.tsx
@@ -0,0 +1,128 @@
+import type { ReactNode } from "react"
+import type { UseQueryResult } from "@tanstack/react-query"
+import { AlertTriangleIcon, RefreshCwIcon } from "lucide-react"
+
+import { ApiError } from "@/api/client"
+import { Button } from "@/components/ui/button"
+import { Skeleton } from "@/components/ui/skeleton"
+
+/**
+ * One place that renders loading, error and empty states for a query.
+ *
+ * The old app handled these three ways: a shared QueryBoundary in some places,
+ * a bespoke inline animate-pulse skeleton in others, and a bare
+ * `Loading messages...
` with no retry in the worst ones. Route them all
+ * through here so the app behaves consistently when the network does not.
+ */
+interface QueryBoundaryProps {
+ query: UseQueryResult
+ children: (data: T) => ReactNode
+ /** Shown while loading. Defaults to generic skeleton lines. */
+ pending?: ReactNode
+ /** Rendered instead of children when the result is empty. */
+ empty?: ReactNode
+ /** Decides emptiness; defaults to empty arrays. */
+ isEmpty?: (data: T) => boolean
+}
+
+export function QueryBoundary({
+ query,
+ children,
+ pending,
+ empty,
+ isEmpty = defaultIsEmpty,
+}: QueryBoundaryProps) {
+ if (query.isPending) {
+ return pending ??
+ }
+
+ if (query.isError) {
+ return (
+ {
+ void query.refetch()
+ }}
+ />
+ )
+ }
+
+ if (empty && isEmpty(query.data)) {
+ return empty
+ }
+
+ return children(query.data)
+}
+
+function defaultIsEmpty(data: unknown): boolean {
+ return Array.isArray(data) && data.length === 0
+}
+
+export function SkeletonLines({ count = 3 }: { count?: number }) {
+ return (
+
+ {Array.from({ length: count }, (_, i) => (
+
+ ))}
+
+ )
+}
+
+export function QueryError({
+ error,
+ onRetry,
+}: {
+ error: unknown
+ onRetry?: () => void
+}) {
+ return (
+
+
+
+
Something went wrong
+
{describeError(error)}
+
+ {onRetry && (
+
+ )}
+
+ )
+}
+
+function describeError(error: unknown): string {
+ if (error instanceof ApiError) {
+ if (error.isForbidden) return "You do not have access to this."
+ if (error.status === 404) return "This no longer exists."
+ if (error.status >= 500) return "The server is having trouble right now."
+ return typeof error.detail === "string" ? error.detail : error.message
+ }
+ return "Check your connection and try again."
+}
+
+export function EmptyState({
+ title,
+ description,
+ action,
+}: {
+ title: string
+ description?: string
+ action?: ReactNode
+}) {
+ return (
+
+
+
{title}
+ {description && (
+
{description}
+ )}
+
+ {action}
+
+ )
+}
diff --git a/web/src/features/auth/api.ts b/web/src/features/auth/api.ts
new file mode 100644
index 00000000..a5558d6e
--- /dev/null
+++ b/web/src/features/auth/api.ts
@@ -0,0 +1,42 @@
+import { queryOptions } from "@tanstack/react-query"
+
+import { ApiError, api, unwrap } from "@/api/client"
+import { qk } from "@/api/query-keys"
+import { type Session, sessionSchema } from "@/features/auth/schema"
+
+/**
+ * The session query. Resolves to null when nobody is signed in, rather than
+ * throwing, so route guards and components can branch on a value.
+ *
+ * The old app tracked auth failure in a module-level `let globalQueryEnabled`
+ * plus sessionStorage plus a forceUpdate counter, to stop React Query retrying
+ * a 401 forever. None of that is needed: retry is off and a 401 is a normal
+ * resolved state.
+ */
+export const sessionQueryOptions = queryOptions({
+ queryKey: qk.session(),
+ queryFn: async (): Promise => {
+ try {
+ const raw = await unwrap(api.GET("/me"))
+ return sessionSchema.parse(raw)
+ } catch (error) {
+ if (error instanceof ApiError && error.isUnauthorized) return null
+ throw error
+ }
+ },
+ staleTime: 1000 * 60 * 5,
+ retry: false,
+})
+
+/**
+ * Login and logout are full-page navigations, not fetches: the backend sets
+ * httpOnly cookies and redirects back. `returnTo` round-trips through the
+ * backend so a deep link survives the OAuth hop.
+ */
+export function beginLogin(returnTo: string = window.location.href) {
+ window.location.href = `/api/login?return_to=${encodeURIComponent(returnTo)}`
+}
+
+export function beginLogout() {
+ window.location.href = "/api/logout"
+}
diff --git a/web/src/features/auth/schema.ts b/web/src/features/auth/schema.ts
new file mode 100644
index 00000000..36c9ee2b
--- /dev/null
+++ b/web/src/features/auth/schema.ts
@@ -0,0 +1,49 @@
+import { z } from "zod"
+
+import type { components } from "@/api/schema"
+
+/**
+ * `/me` is the one endpoint whose payload the generated types cannot describe:
+ * the backend declares it as `user: Dict[str, Any]` (CurrentUserResponse in
+ * backend/modules/auth/schemas.py), so OpenAPI reports an opaque index
+ * signature. Parsing here adds real information rather than restating a
+ * contract the compiler already knows — everywhere else, use the generated
+ * types instead of writing a schema by hand.
+ */
+
+/** Reuses the generated enum so a backend role change surfaces as a type error. */
+type UserRole = components["schemas"]["UserRole"]
+
+const USER_ROLES = [
+ "default",
+ "admin",
+ "boss",
+ "capo",
+ "soldier",
+ "community_admin",
+] as const satisfies readonly UserRole[]
+
+export const userRoleSchema = z.enum(USER_ROLES)
+
+export const currentUserSchema = z.object({
+ sub: z.string(),
+ email: z.email(),
+ given_name: z.string(),
+ family_name: z.string(),
+ name: z.string(),
+ role: userRoleSchema.catch("default"),
+ /** Community ids this user heads; drives community_admin permissions. */
+ communities: z.array(z.number()).default([]),
+ /** Set for Student Government members; scopes ticket delegation. */
+ department_id: z.number().nullable().default(null),
+})
+
+export type CurrentUser = z.infer
+
+export const sessionSchema = z.object({
+ user: currentUserSchema,
+ /** Null until the user links their Telegram account via /connect-tg. */
+ tg_id: z.number().nullable().default(null),
+})
+
+export type Session = z.infer
diff --git a/web/src/features/auth/use-session.ts b/web/src/features/auth/use-session.ts
new file mode 100644
index 00000000..16540618
--- /dev/null
+++ b/web/src/features/auth/use-session.ts
@@ -0,0 +1,53 @@
+import { useSuspenseQuery } from "@tanstack/react-query"
+
+import { sessionQueryOptions } from "@/features/auth/api"
+import type { CurrentUser, Session } from "@/features/auth/schema"
+
+/** The current session, or null when signed out. */
+export function useSession(): Session | null {
+ return useSuspenseQuery(sessionQueryOptions).data
+}
+
+/**
+ * The signed-in user. Only valid under the `_app` layout route, whose
+ * beforeLoad guard has already redirected anonymous visitors away — so this
+ * throws rather than returning null and forcing every caller to re-check.
+ */
+export function useCurrentUser(): CurrentUser {
+ const session = useSession()
+ if (!session) {
+ throw new Error(
+ "useCurrentUser called outside an authenticated route. Use useSession in public routes."
+ )
+ }
+ return session.user
+}
+
+/**
+ * Permission checks derived from the user's role and scopes, so authorization
+ * lives in one place. The old app gated opportunity authoring on a hardcoded
+ * client-side email allowlist that still contained bob@example.com.
+ */
+export function usePermissions() {
+ const session = useSession()
+ const user = session?.user
+
+ const role = user?.role ?? "default"
+ const isAdmin = role === "admin"
+ // boss > capo > soldier is the Student Government hierarchy.
+ const isSgMember = role === "boss" || role === "capo" || role === "soldier"
+
+ return {
+ role,
+ isAdmin,
+ isSgMember,
+ isSgLead: role === "boss",
+ hasTelegramLinked: session?.tg_id != null,
+ /** True when the user heads this community, or is an admin. */
+ canManageCommunity: (communityId: number) =>
+ isAdmin || (user?.communities.includes(communityId) ?? false),
+ /** Authoring the opportunities digest is an admin capability. */
+ canManageOpportunities: isAdmin,
+ canDelegateTickets: isAdmin || role === "boss" || role === "capo",
+ }
+}
diff --git a/web/src/lib/datetime.ts b/web/src/lib/datetime.ts
new file mode 100644
index 00000000..4aa54ac3
--- /dev/null
+++ b/web/src/lib/datetime.ts
@@ -0,0 +1,105 @@
+/**
+ * Datetime handling at the API boundary.
+ *
+ * The backend treats a naive datetime as Almaty local time and converts it to
+ * UTC on the way in (`almaty_to_utc` in backend/common/datetime_utils.py). So
+ * a value sent without an offset is interpreted in Almaty, not in the user's
+ * timezone and not in UTC.
+ *
+ * Conversions belong here rather than in components, so there is exactly one
+ * place where a mistake can hide.
+ */
+
+export const CAMPUS_TIME_ZONE = "Asia/Almaty"
+
+/**
+ * Formats an instant in campus time. Events happen on campus, so a student
+ * travelling abroad should still see the local start time of the event.
+ */
+export function formatCampusDateTime(
+ iso: string,
+ options: Intl.DateTimeFormatOptions = {
+ dateStyle: "medium",
+ timeStyle: "short",
+ }
+): string {
+ return new Intl.DateTimeFormat("en-GB", {
+ ...options,
+ timeZone: CAMPUS_TIME_ZONE,
+ }).format(new Date(iso))
+}
+
+export function formatCampusDate(iso: string): string {
+ return formatCampusDateTime(iso, { dateStyle: "medium" })
+}
+
+export function formatCampusTime(iso: string): string {
+ return formatCampusDateTime(iso, { timeStyle: "short" })
+}
+
+/**
+ * Splits an instant into the calendar fields a date/time input expects,
+ * expressed in campus time — `new Date().toISOString().slice(0, 16)` would
+ * silently use the browser's timezone instead.
+ */
+export function toCampusInputValue(iso: string): {
+ date: string
+ time: string
+} {
+ const parts = new Intl.DateTimeFormat("en-CA", {
+ timeZone: CAMPUS_TIME_ZONE,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ }).formatToParts(new Date(iso))
+
+ const get = (type: Intl.DateTimeFormatPartTypes) =>
+ parts.find((part) => part.type === type)?.value ?? "00"
+
+ return {
+ date: `${get("year")}-${get("month")}-${get("day")}`,
+ // Intl renders midnight as "24" in some engines.
+ time: `${get("hour") === "24" ? "00" : get("hour")}:${get("minute")}`,
+ }
+}
+
+/**
+ * Builds the naive datetime string the backend expects from form fields.
+ * Deliberately carries no offset: the backend applies Almaty itself, and
+ * appending "Z" here would shift every submitted time by the UTC offset.
+ */
+export function toCampusNaiveDateTime(date: string, time: string): string {
+ return `${date}T${time.length === 5 ? `${time}:00` : time}`
+}
+
+/** Whether an instant has already passed. */
+export function isPast(iso: string): boolean {
+ return new Date(iso).getTime() < Date.now()
+}
+
+/**
+ * Coarse relative time ("in 3 days", "2 hours ago") via Intl, so it localises
+ * correctly rather than through hand-written string concatenation.
+ */
+const RELATIVE_UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
+ ["year", 1000 * 60 * 60 * 24 * 365],
+ ["month", 1000 * 60 * 60 * 24 * 30],
+ ["day", 1000 * 60 * 60 * 24],
+ ["hour", 1000 * 60 * 60],
+ ["minute", 1000 * 60],
+]
+
+export function formatRelative(iso: string, now: number = Date.now()): string {
+ const formatter = new Intl.RelativeTimeFormat("en", { numeric: "auto" })
+ const diff = new Date(iso).getTime() - now
+
+ for (const [unit, ms] of RELATIVE_UNITS) {
+ if (Math.abs(diff) >= ms) {
+ return formatter.format(Math.round(diff / ms), unit)
+ }
+ }
+ return formatter.format(Math.round(diff / 1000), "second")
+}
diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts
index d204c269..6e93748d 100644
--- a/web/src/routeTree.gen.ts
+++ b/web/src/routeTree.gen.ts
@@ -10,33 +10,49 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
+import { Route as AppRouteImport } from './routes/_app'
+import { Route as AppAnnouncementsRouteImport } from './routes/_app/announcements'
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
+const AppRoute = AppRouteImport.update({
+ id: '/_app',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const AppAnnouncementsRoute = AppAnnouncementsRouteImport.update({
+ id: '/announcements',
+ path: '/announcements',
+ getParentRoute: () => AppRoute,
+} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
+ '/announcements': typeof AppAnnouncementsRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
+ '/announcements': typeof AppAnnouncementsRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
+ '/_app': typeof AppRouteWithChildren
+ '/_app/announcements': typeof AppAnnouncementsRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
- fullPaths: '/'
+ fullPaths: '/' | '/announcements'
fileRoutesByTo: FileRoutesByTo
- to: '/'
- id: '__root__' | '/'
+ to: '/' | '/announcements'
+ id: '__root__' | '/' | '/_app' | '/_app/announcements'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
+ AppRoute: typeof AppRouteWithChildren
}
declare module '@tanstack/react-router' {
@@ -48,11 +64,36 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
+ '/_app': {
+ id: '/_app'
+ path: ''
+ fullPath: '/'
+ preLoaderRoute: typeof AppRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/_app/announcements': {
+ id: '/_app/announcements'
+ path: '/announcements'
+ fullPath: '/announcements'
+ preLoaderRoute: typeof AppAnnouncementsRouteImport
+ parentRoute: typeof AppRoute
+ }
}
}
+interface AppRouteChildren {
+ AppAnnouncementsRoute: typeof AppAnnouncementsRoute
+}
+
+const AppRouteChildren: AppRouteChildren = {
+ AppAnnouncementsRoute: AppAnnouncementsRoute,
+}
+
+const AppRouteWithChildren = AppRoute._addFileChildren(AppRouteChildren)
+
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
+ AppRoute: AppRouteWithChildren,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
diff --git a/web/src/routes/_app.tsx b/web/src/routes/_app.tsx
new file mode 100644
index 00000000..dc54185f
--- /dev/null
+++ b/web/src/routes/_app.tsx
@@ -0,0 +1,31 @@
+import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"
+
+import { sessionQueryOptions } from "@/features/auth/api"
+
+/**
+ * Authenticated shell. Every route beneath it is guarded here, once — the old
+ * app named a component ProtectedLayout but performed no auth check in it,
+ * leaving each page to call useUser() and handle redirects itself.
+ */
+export const Route = createFileRoute("/_app")({
+ beforeLoad: async ({ context, location }) => {
+ const session =
+ await context.queryClient.ensureQueryData(sessionQueryOptions)
+ if (!session) {
+ throw redirect({ to: "/", search: { returnTo: location.href } })
+ }
+ // Downstream routes and components read this without re-fetching.
+ return { session }
+ },
+ component: AppLayout,
+})
+
+function AppLayout() {
+ return (
+
+
+
+
+
+ )
+}
diff --git a/web/src/routes/_app/announcements.tsx b/web/src/routes/_app/announcements.tsx
new file mode 100644
index 00000000..8c10c4f4
--- /dev/null
+++ b/web/src/routes/_app/announcements.tsx
@@ -0,0 +1,29 @@
+import { createFileRoute } from "@tanstack/react-router"
+
+import { useCurrentUser } from "@/features/auth/use-session"
+import { beginLogout } from "@/features/auth/api"
+import { Button } from "@/components/ui/button"
+
+export const Route = createFileRoute("/_app/announcements")({
+ component: Announcements,
+})
+
+function Announcements() {
+ const user = useCurrentUser()
+
+ return (
+
+
+
+ Hi, {user.given_name}
+
+
+
+
+ Signed in as {user.email} ({user.role}).
+
+
+ )
+}
diff --git a/web/src/routes/index.tsx b/web/src/routes/index.tsx
index 5ef0dcdb..8911cc8f 100644
--- a/web/src/routes/index.tsx
+++ b/web/src/routes/index.tsx
@@ -1,17 +1,52 @@
-import { createFileRoute } from "@tanstack/react-router"
+import { createFileRoute, redirect } from "@tanstack/react-router"
+import { z } from "zod"
+
+import { beginLogin, sessionQueryOptions } from "@/features/auth/api"
+import { Button } from "@/components/ui/button"
+
+/**
+ * Search params are parsed, not read as raw strings. `returnTo` is set by the
+ * _app guard when it bounces an anonymous visitor, so signing in returns them
+ * to the page they actually asked for.
+ */
+const landingSearchSchema = z.object({
+ returnTo: z.string().optional(),
+})
export const Route = createFileRoute("/")({
- component: Home,
+ validateSearch: landingSearchSchema,
+ beforeLoad: async ({ context }) => {
+ const session =
+ await context.queryClient.ensureQueryData(sessionQueryOptions)
+ if (session) {
+ throw redirect({ to: "/announcements" })
+ }
+ },
+ component: Landing,
})
-function Home() {
+function Landing() {
+ const { returnTo } = Route.useSearch()
+
return (
-
-
-
Nuspace
-
- New frontend — scaffold running.
-
+
+
+
+
Nuspace
+
+ Campus services, announcements, and student essentials for NU, in
+ one place.
+
+
+
)
From 1992a3513c918203aa816e50cdb6db3ebd031d7a Mon Sep 17 00:00:00 2001
From: Yerassyl Auyeskhan <95128018+ieraasyl@users.noreply.github.com>
Date: Sun, 26 Jul 2026 02:28:52 +0500
Subject: [PATCH 06/56] chore(infra): serve the new frontend on 6767
---
infra/docker-compose.yml | 4 ++--
infra/nginx/nginx.dev.conf | 7 +++----
2 files changed, 5 insertions(+), 6 deletions(-)
diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml
index 4c4fdcc2..3e308484 100644
--- a/infra/docker-compose.yml
+++ b/infra/docker-compose.yml
@@ -42,7 +42,7 @@ services:
restart: always
ports:
- "80:80" # Existing frontend/
- - "8081:8081" # New web/ — remove once the cutover lands
+ - "6767:6767" # New web/ — remove once the cutover lands
volumes:
- ./nginx/nginx.dev.conf:/etc/nginx/nginx.conf
- ../frontend/dist:/var/www/my-app/dist # Make sure this folder exists in production builds
@@ -159,7 +159,7 @@ services:
- cloudflared
# New frontend, served side by side with `frontend` so the two can be compared
- # against one backend during the rewrite. Reachable at http://localhost:8080.
+ # against one backend during the rewrite. Reachable at http://localhost:6767.
web:
build:
context: ..
diff --git a/infra/nginx/nginx.dev.conf b/infra/nginx/nginx.dev.conf
index 3dea1543..d06e7063 100644
--- a/infra/nginx/nginx.dev.conf
+++ b/infra/nginx/nginx.dev.conf
@@ -38,7 +38,7 @@ http {
1 /api/og$request_uri;
}
- # Same routing, but pointed at the new frontend (see the :8080 server below).
+ # Same routing, but pointed at the new frontend (see the :6767 server below).
map $is_bot $web_upstream {
0 http://web:5173;
1 http://fastapi:8000;
@@ -115,11 +115,10 @@ http {
# document root differs. Remove this block at cutover, when :80 starts
# serving web/ directly.
#
- # 8081 rather than 8080: the latter is a common default and was already
- # taken on the dev machine this was first brought up on.
+ # Not 8080: pgadmin already publishes that port in this compose file.
# ------------------------------------------------------------------------
server {
- listen 8081;
+ listen 6767;
server_name localhost;
client_max_body_size 100m;
From ce0b33e537c40c3b25037544991bfea426ab1d03 Mon Sep 17 00:00:00 2001
From: Yerassyl Auyeskhan <95128018+ieraasyl@users.noreply.github.com>
Date: Sun, 26 Jul 2026 02:34:57 +0500
Subject: [PATCH 07/56] feat(web): add app shell, infinite-list primitives, and
route skeleton
---
web/src/components/app-sidebar.tsx | 133 +++++++++++++++++++++
web/src/components/infinite-list.tsx | 70 +++++++++++
web/src/components/ui/sheet.tsx | 135 +++++++++++++++++++++
web/src/hooks/use-infinite-list.ts | 82 +++++++++++++
web/src/hooks/use-intersection.ts | 37 ++++++
web/src/main.tsx | 2 +
web/src/routeTree.gen.ts | 161 +++++++++++++++++++++++++-
web/src/routes/_app.tsx | 8 +-
web/src/routes/_app/communities.tsx | 16 +++
web/src/routes/_app/contacts.tsx | 16 +++
web/src/routes/_app/courses.tsx | 16 +++
web/src/routes/_app/events.tsx | 16 +++
web/src/routes/_app/opportunities.tsx | 16 +++
web/src/routes/_app/profile.tsx | 16 +++
web/src/routes/_app/sgotinish.tsx | 16 +++
15 files changed, 735 insertions(+), 5 deletions(-)
create mode 100644 web/src/components/app-sidebar.tsx
create mode 100644 web/src/components/infinite-list.tsx
create mode 100644 web/src/components/ui/sheet.tsx
create mode 100644 web/src/hooks/use-infinite-list.ts
create mode 100644 web/src/hooks/use-intersection.ts
create mode 100644 web/src/routes/_app/communities.tsx
create mode 100644 web/src/routes/_app/contacts.tsx
create mode 100644 web/src/routes/_app/courses.tsx
create mode 100644 web/src/routes/_app/events.tsx
create mode 100644 web/src/routes/_app/opportunities.tsx
create mode 100644 web/src/routes/_app/profile.tsx
create mode 100644 web/src/routes/_app/sgotinish.tsx
diff --git a/web/src/components/app-sidebar.tsx b/web/src/components/app-sidebar.tsx
new file mode 100644
index 00000000..b8fb521b
--- /dev/null
+++ b/web/src/components/app-sidebar.tsx
@@ -0,0 +1,133 @@
+import { useState } from "react"
+import { Link, useRouterState } from "@tanstack/react-router"
+import {
+ BookOpenIcon,
+ BriefcaseIcon,
+ CalendarIcon,
+ InfoIcon,
+ MenuIcon,
+ MegaphoneIcon,
+ ShieldIcon,
+ UserIcon,
+ UsersIcon,
+} from "lucide-react"
+import type { LinkProps } from "@tanstack/react-router"
+import type { LucideIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import {
+ Sheet,
+ SheetContent,
+ SheetTitle,
+ SheetTrigger,
+} from "@/components/ui/sheet"
+
+interface NavItem {
+ /**
+ * Typed against the generated route tree, so a link to a route that does not
+ * exist fails the build. The old app's Link wrapper took a plain string and
+ * threw this away.
+ */
+ to: LinkProps["to"]
+ label: string
+ icon: LucideIcon
+}
+
+const NAV_ITEMS: NavItem[] = [
+ { to: "/announcements", label: "Home", icon: MegaphoneIcon },
+ { to: "/events", label: "Events", icon: CalendarIcon },
+ { to: "/communities", label: "Communities", icon: UsersIcon },
+ { to: "/courses", label: "Courses", icon: BookOpenIcon },
+ { to: "/opportunities", label: "Opportunities", icon: BriefcaseIcon },
+ { to: "/contacts", label: "Contacts", icon: InfoIcon },
+ { to: "/sgotinish", label: "SG otinish", icon: ShieldIcon },
+ { to: "/profile", label: "Profile", icon: UserIcon },
+]
+
+function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
+ const pathname = useRouterState({ select: (s) => s.location.pathname })
+
+ return (
+
+ )
+}
+
+function SidebarBrand() {
+ return (
+
+ Nuspace
+
+ )
+}
+
+/**
+ * App navigation: a fixed rail on desktop, a sheet on mobile.
+ *
+ * The old sidebar injected a raw