Compare commits
2 Commits
66dc94bc22
...
1f541c43b5
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f541c43b5 | |||
| 2ba4afa252 |
@@ -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",
|
||||
|
||||
262
apps/client/src/components/Header.tsx
Normal file
262
apps/client/src/components/Header.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
41
apps/client/src/components/ThemeModeSwitcher.tsx
Normal file
41
apps/client/src/components/ThemeModeSwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
apps/client/src/components/UserMenu.tsx
Normal file
103
apps/client/src/components/UserMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
apps/client/src/components/ui/avatar.tsx
Normal file
51
apps/client/src/components/ui/avatar.tsx
Normal 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 }
|
||||
137
apps/client/src/components/ui/sheet.tsx
Normal file
137
apps/client/src/components/ui/sheet.tsx
Normal 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,
|
||||
}
|
||||
13
apps/client/src/components/ui/skeleton.tsx
Normal file
13
apps/client/src/components/ui/skeleton.tsx
Normal 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 }
|
||||
@@ -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>,
|
||||
|
||||
3
apps/client/src/routes/home/index.tsx
Normal file
3
apps/client/src/routes/home/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export function HomeIndexPage() {
|
||||
return <div className="container mx-auto">Home page</div>;
|
||||
}
|
||||
12
apps/client/src/routes/home/layout.tsx
Normal file
12
apps/client/src/routes/home/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./library";
|
||||
export * from "./onboard";
|
||||
|
||||
42
apps/server/src/models/api/library.ts
Normal file
42
apps/server/src/models/api/library.ts
Normal 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>;
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from "./api";
|
||||
export * as API from "./api";
|
||||
export * from "./credits";
|
||||
export * from "./language";
|
||||
export * from "./metadata";
|
||||
|
||||
103
apps/server/src/routes/library.ts
Normal file
103
apps/server/src/routes/library.ts
Normal 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 };
|
||||
@@ -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 };
|
||||
|
||||
5
bun.lock
5
bun.lock
@@ -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=="],
|
||||
|
||||
Reference in New Issue
Block a user