Compare commits

...

2 Commits

Author SHA1 Message Date
1f541c43b5 feat(client): add header component with navigation, user menu, and home routes
- Implement Header component with responsive navigation sheet, back/home buttons, and search link
- Add ThemeModeSwitcher for dark/light mode toggle
- Create UserMenu with avatar, dropdown, and logout functionality
- Add UI components: Avatar, Sheet, and Skeleton
- Update Providers to remove TRPC integration and configure QueryClient
- Add home layout and index routes with Header wrapper
- Update dependencies including @radix-ui/react-avatar
2025-10-30 13:40:17 +00:00
2ba4afa252 feat(server): add library API endpoints and schemas
- Add Zod schemas for library titles and all libraries in models/api/library.ts
- Create new library router with /all and /titles endpoints
- Update onboard router to use namespaced API imports
- Export API models as namespace in models/index.ts
2025-10-30 13:39:58 +00:00
17 changed files with 803 additions and 36 deletions

View File

@@ -14,6 +14,7 @@
"dependencies": {
"@nontara/language-codes": "workspace:*",
"@nontara/server": "workspace:*",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",

View File

@@ -0,0 +1,262 @@
import type { Models } from "@nontara/server/models";
import { useQuery } from "@tanstack/react-query";
import {
ArrowLeft,
Film,
Folder,
FolderTree,
Home,
Image,
LayoutDashboard,
Menu,
Search,
ShieldCheck,
Tv,
} from "lucide-react";
import type { PropsWithChildren } from "react";
import { memo, useCallback, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router";
import logoSrc from "@/assets/logo.png";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { apiClient } from "@/lib/api";
import { ThemeModeSwitcher } from "./ThemeModeSwitcher";
import UserMenu from "./UserMenu";
type NavigationSheetContentProps = {
onNavigate?: () => void;
};
const NavigationSheetContent = memo(function NavigationSheetContent({
onNavigate,
}: NavigationSheetContentProps) {
const {
data: libraries,
isLoading,
isError,
} = useQuery({
queryKey: ["library-all"],
queryFn: async () => {
const response =
await apiClient.get<Models.API.LibraryAllOutput>("library/all");
return await response.json();
},
});
return (
<div className="flex h-full flex-col bg-background">
<div className="flex items-center gap-2 border-border border-b px-4 py-3">
<img
src={logoSrc}
alt="Nontara logo"
className="h-10 w-auto select-none"
loading="eager"
fetchPriority="high"
decoding="async"
draggable={false}
/>
</div>
<nav className="flex-1 overflow-y-auto px-2 py-4">
<div className="space-y-6">
<section>
<ul className="grid gap-1">
<li>
<Button
asChild
variant="ghost"
className="w-full justify-start gap-2 px-3 py-2 font-medium text-sm"
>
<Link to="/home" onClick={onNavigate}>
<Home className="size-4" aria-hidden="true" />
<span>Home</span>
</Link>
</Button>
</li>
</ul>
</section>
<section>
<div className="flex items-center gap-2 px-3 pb-1 font-semibold text-muted-foreground text-xs uppercase tracking-wide">
<FolderTree className="size-4" aria-hidden="true" />
<span>Media</span>
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2 rounded-md px-3 py-2 font-medium text-sm">
<Image className="size-4" aria-hidden="true" />
<span>Media Library</span>
</div>
{isLoading ? (
<div className="ml-6 border-border border-l pl-3">
<div className="px-2 py-1.5 text-muted-foreground text-sm">
Loading...
</div>
</div>
) : libraries && libraries.length > 0 ? (
<ul className="ml-6 space-y-0.5 border-border border-l pl-3">
{libraries.map((library) => {
const LibraryIcon =
library.type === "MOVIE"
? Film
: library.type === "SERIES"
? Tv
: Folder;
return (
<li key={library.id}>
<Button
asChild
variant="ghost"
className="flex w-full justify-start gap-2 rounded-md px-2 py-1.5 text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground"
>
<Link
to={`/home/library/${library.id.toString()}`}
onClick={onNavigate}
>
<LibraryIcon
className="size-4"
aria-hidden="true"
/>
<span>{library.name}</span>
</Link>
</Button>
</li>
);
})}
</ul>
) : isError ? (
<div className="ml-6 border-border border-l pl-3">
<div className="px-2 py-1.5 text-muted-foreground text-sm">
Failed to load libraries
</div>
</div>
) : (
<div className="ml-6 border-border border-l pl-3">
<div className="px-2 py-1.5 text-muted-foreground text-sm">
No libraries found
</div>
</div>
)}
</div>
</section>
<section>
<div className="flex items-center gap-2 px-3 pb-1 font-semibold text-muted-foreground text-xs uppercase tracking-wide">
<ShieldCheck className="size-4" aria-hidden="true" />
<span>Administration</span>
</div>
<ul className="grid gap-1">
<li>
<Button
asChild
variant="ghost"
className="w-full justify-start gap-2 px-3 py-2 font-medium text-sm"
>
<Link to="/dashboard">
<LayoutDashboard className="size-4" aria-hidden="true" />
<span>Dashboard</span>
</Link>
</Button>
</li>
</ul>
</section>
</div>
</nav>
</div>
);
});
NavigationSheetContent.displayName = "NavigationSheetContent";
function HeaderBar() {
const [open, setOpen] = useState(false);
const handleNavigate = useCallback(() => setOpen(false), []);
const { pathname } = useLocation();
const navigate = useNavigate();
return (
<header className="sticky top-0 z-50 flex items-center justify-between border-border border-b bg-muted/95 px-5 py-3 shadow-sm backdrop-blur supports-backdrop-filter:bg-muted/80">
<div className="flex items-center gap-3">
{pathname !== "/home" && (
<>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Go back"
onClick={() => navigate(-1)}
>
<ArrowLeft className="size-5" aria-hidden="true" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Go to home"
asChild
>
<Link to="/home">
<Home className="size-5" aria-hidden="true" />
</Link>
</Button>
</>
)}
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
type="button"
variant="ghost"
size="lg"
className="px-3"
aria-label="Open navigation menu"
>
<Menu className="size-5" aria-hidden="true" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-[18rem] p-0 sm:w-[20rem]">
<NavigationSheetContent onNavigate={handleNavigate} />
</SheetContent>
</Sheet>
{pathname === "/home" && (
<Link to="/home" className="flex items-center">
<img
src={logoSrc}
alt="Nontara logo"
className="h-9 w-auto select-none"
loading="eager"
fetchPriority="high"
decoding="async"
draggable={false}
/>
</Link>
)}
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Search"
asChild
>
<Link to="/home/search">
<Search className="size-5" aria-hidden="true" />
</Link>
</Button>
<ThemeModeSwitcher />
<UserMenu />
</div>
</header>
);
}
const Header = memo(function Header({ children }: PropsWithChildren) {
return (
<div className="flex min-h-svh w-full flex-1 flex-col bg-background">
<HeaderBar />
{children}
</div>
);
});
Header.displayName = "Header";
export default Header;

View File

@@ -1,27 +1,21 @@
import type { AppRouter } from "@nontara/server/shared";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import type React from "react";
import { TRPCProvider } from "@/lib/trpc";
import { ThemeProvider } from "./providers/ThemeProvider";
const queryClient = new QueryClient();
const trpcClient = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: `${import.meta.env.VITE_SERVER_URL}/trpc`,
}),
],
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
retryDelay: 2,
refetchOnWindowFocus: false,
},
},
});
export default function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider storageKey="theme-mode">
<QueryClientProvider client={queryClient}>
<TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
{children}
</TRPCProvider>
</QueryClientProvider>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</ThemeProvider>
);
}

View File

@@ -0,0 +1,41 @@
"use client";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import * as React from "react";
import { Button } from "@/components/ui/button";
export function ThemeModeSwitcher() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<Button variant="ghost" size="icon" aria-label="Toggle theme">
<Sun className="size-5" />
</Button>
);
}
const isDark = theme === "dark";
return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(isDark ? "light" : "dark")}
aria-label="Toggle theme"
>
{isDark ? (
<Sun className="size-5" aria-hidden="true" />
) : (
<Moon className="size-5" aria-hidden="true" />
)}
</Button>
);
}

View File

@@ -0,0 +1,103 @@
import { LayoutDashboard, LogOutIcon } from "lucide-react";
import React from "react";
import { Link, useNavigate } from "react-router";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Skeleton } from "@/components/ui/skeleton";
import { authClient } from "@/lib/auth-client";
export default function UserMenu() {
const navigate = useNavigate();
const { data: session, isPending } = authClient.useSession();
const [open, setOpen] = React.useState(false);
if (isPending) {
return <Skeleton className="h-9 w-9 rounded-lg" />;
}
if (!session) {
return null;
}
const user = session.user;
const userInitials = user.name
? user.name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
: user.email?.charAt(0).toUpperCase() || "U";
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger>
<Avatar className="size-6 rounded-lg">
<AvatarImage
src={user.image || undefined}
alt={user.name || "User"}
/>
<AvatarFallback className="rounded-lg">{userInitials}</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="size-8 rounded-lg">
<AvatarImage
src={user.image || undefined}
alt={user.name || "User"}
/>
<AvatarFallback className="rounded-lg">
{userInitials}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{user.name || "User"}
</span>
<span className="truncate text-muted-foreground text-xs">
{user.email}
</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<Link to="/dashboard" onClick={() => setOpen(false)}>
<LayoutDashboard />
Dashboard
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600!"
onClick={() => {
authClient.signOut({
fetchOptions: {
onSuccess: () => {
navigate("/");
},
},
});
}}
>
<LogOutIcon className="text-red-600!" />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,51 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,137 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -2,6 +2,8 @@ import { createRoot } from "react-dom/client";
import "./index.css";
import { BrowserRouter, Route, Routes } from "react-router";
import DefaultLayout from "./components/DefaultLayout.tsx";
import { HomeIndexPage } from "./routes/home/index.tsx";
import { HomeLayout } from "./routes/home/layout.tsx";
import IndexPage from "./routes/index.tsx";
import { LoginPage } from "./routes/login.tsx";
import { OnboardCompletePage } from "./routes/onboard/complete.tsx";
@@ -28,6 +30,9 @@ root.render(
<Route path="login" element={<LoginPage />} />
<Route path="signup" element={<SignupPage />} />
</Route>
<Route path="home" element={<HomeLayout />}>
<Route index element={<HomeIndexPage />} />
</Route>
</Route>
</Routes>
</BrowserRouter>,

View File

@@ -0,0 +1,3 @@
export function HomeIndexPage() {
return <div className="container mx-auto">Home page</div>;
}

View File

@@ -0,0 +1,12 @@
import { Outlet } from "react-router";
import Header from "@/components/Header";
export function HomeLayout() {
return (
<Header>
<main className="flex flex-1 flex-col gap-6">
<Outlet />
</main>
</Header>
);
}

View File

@@ -1 +1,2 @@
export * from "./library";
export * from "./onboard";

View File

@@ -0,0 +1,42 @@
import { z } from "zod";
export const LibraryTitlesInput = z.object({
libraryId: z.number(),
page: z.number().min(1).default(1),
limit: z.number().min(1).max(100).default(20),
});
export const LibraryTitlesOutput = z.object({
titles: z.array(
z.object({
id: z.string(),
title: z.string(),
year: z.number().nullable(),
poster: z.string().nullable(),
type: z.string(),
}),
),
pagination: z.object({
total: z.number(),
page: z.number(),
limit: z.number(),
totalPages: z.number(),
}),
});
export type LibraryTitlesInput = z.infer<typeof LibraryTitlesInput>;
export type LibraryTitlesOutput = z.infer<typeof LibraryTitlesOutput>;
export const LibraryAllInput = z.void();
export const LibraryAllOutput = z.array(
z.object({
id: z.number(),
name: z.string(),
type: z.enum(["MOVIE", "SERIES"]),
path: z.string(),
}),
);
export type LibraryAllInput = z.infer<typeof LibraryAllInput>;
export type LibraryAllOutput = z.infer<typeof LibraryAllOutput>;

View File

@@ -1,4 +1,4 @@
export * from "./api";
export * as API from "./api";
export * from "./credits";
export * from "./language";
export * from "./metadata";

View File

@@ -0,0 +1,103 @@
import { zValidator } from "@hono/zod-validator";
import { count, desc, eq } from "drizzle-orm";
import { Hono } from "hono";
import { createMiddleware } from "hono/factory";
import { database } from "@/db";
import { libraryFolders, titles } from "@/db/schema";
import auth from "@/lib/auth";
import { createHTTPException } from "@/lib/errors";
import type { ExtendHonoEnv } from "@/lib/types";
import {
type LibraryAllOutput,
LibraryTitlesInput,
type LibraryTitlesOutput,
} from "@/models/api/library";
type LibraryHonoEnv = ExtendHonoEnv<{
session: Exclude<Awaited<ReturnType<typeof auth.api.getSession>>, null>;
}>;
const authenticationMiddleware = createMiddleware<LibraryHonoEnv>(
async (c, next) => {
const session = await auth.api.getSession({
headers: c.req.raw.headers,
});
if (!session) {
throw createHTTPException(
401,
"Authentication required. Please log in to continue.",
);
}
c.set("session", session);
return await next();
},
);
const LibraryRouter = new Hono<LibraryHonoEnv>()
.use(authenticationMiddleware)
.get("/all", async (c) => {
const libraries = await database
.select({
id: libraryFolders.id,
name: libraryFolders.name,
type: libraryFolders.type,
path: libraryFolders.path,
})
.from(libraryFolders)
.orderBy(libraryFolders.name);
return c.json<LibraryAllOutput>(libraries);
})
.get("/titles", zValidator("param", LibraryTitlesInput), async (c) => {
const { libraryId, page, limit } = c.req.valid("param");
// Verify library exists
const library = await database
.select({ id: libraryFolders.id })
.from(libraryFolders)
.where(eq(libraryFolders.id, libraryId))
.limit(1);
if (library.length === 0) {
throw createHTTPException(404, "Library not found");
}
// Get total count
const totalCountResult = await database
.select({ count: count() })
.from(titles)
.where(eq(titles.libraryFolderId, libraryId));
const total = totalCountResult[0].count;
const totalPages = Math.ceil(total / limit);
// Calculate offset
const offset = (page - 1) * limit;
// Get paginated titles
const titlesData = await database
.select({
id: titles.id,
title: titles.title,
year: titles.year,
poster: titles.poster,
type: titles.type,
})
.from(titles)
.where(eq(titles.libraryFolderId, libraryId))
.orderBy(desc(titles.createdAt))
.limit(limit)
.offset(offset);
return c.json<LibraryTitlesOutput>({
titles: titlesData,
pagination: {
total,
page,
limit,
totalPages,
},
});
});
export { LibraryRouter };

View File

@@ -5,12 +5,8 @@ import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { createHTTPException } from "@/lib/errors";
import type { ExtendHonoEnv } from "@/lib/types";
import type {
Directory,
OnboardCompletedOutput,
OnboardStatusOutput,
} from "@/models";
import { OnboardCompletedInput, Path } from "@/models";
import type { Directory } from "@/models";
import { API, Path } from "@/models";
import { OnboardService } from "@/services/onboard";
type OnboardRouterEnv = {
@@ -26,7 +22,7 @@ const OnboardRouter = new Hono<ExtendHonoEnv<OnboardRouterEnv>>()
return next();
})
.get("/status", (c) => {
return c.json<OnboardStatusOutput>({
return c.json<API.OnboardStatusOutput>({
needsOnboarding: c.get("needsOnboarding"),
});
})
@@ -136,14 +132,18 @@ const OnboardRouter = new Hono<ExtendHonoEnv<OnboardRouterEnv>>()
}
})
.post("/complete", zValidator("json", OnboardCompletedInput), async (c) => {
const body = c.req.valid("json");
const service = c.get("service");
const onboardService = service.get(OnboardService);
await onboardService.completeOnboarding(body);
return c.json<OnboardCompletedOutput>({
success: true,
completed: true,
});
});
.post(
"/complete",
zValidator("json", API.OnboardCompletedInput),
async (c) => {
const body = c.req.valid("json");
const service = c.get("service");
const onboardService = service.get(OnboardService);
await onboardService.completeOnboarding(body);
return c.json<API.OnboardCompletedOutput>({
success: true,
completed: true,
});
},
);
export { OnboardRouter };

View File

@@ -16,6 +16,7 @@
"dependencies": {
"@nontara/language-codes": "workspace:*",
"@nontara/server": "workspace:*",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
@@ -1248,7 +1249,7 @@
"hls.js": ["hls.js@1.6.13", "", {}, "sha512-hNEzjZNHf5bFrUNvdS4/1RjIanuJ6szpWNfTaX5I6WfGynWXGT7K/YQLYtemSvFExzeMdgdE4SsyVLJbd5PcZA=="],
"hono": ["hono@4.10.3", "", {}, "sha512-2LOYWUbnhdxdL8MNbNg9XZig6k+cZXm5IjHn2Aviv7honhBMOHb+jxrKIeJRZJRmn+htUCKhaicxwXuUDlchRA=="],
"hono": ["hono@4.10.4", "", {}, "sha512-YG/fo7zlU3KwrBL5vDpWKisLYiM+nVstBQqfr7gCPbSYURnNEP9BDxEMz8KfsDR9JX0lJWDRNc6nXX31v7ZEyg=="],
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
@@ -1860,8 +1861,6 @@
"bun-types/@types/node": ["@types/node@20.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg=="],
"client/hono": ["hono@4.10.4", "", {}, "sha512-YG/fo7zlU3KwrBL5vDpWKisLYiM+nVstBQqfr7gCPbSYURnNEP9BDxEMz8KfsDR9JX0lJWDRNc6nXX31v7ZEyg=="],
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],