Compare commits

...

23 Commits

Author SHA1 Message Date
a7a521e6ad refactor(web-client): update library route query key and navigation
- Include page in library titles query key for proper caching
- Add replace option to navigation for history management
- Adjust skeleton aspect ratio class for consistency
2025-11-08 02:39:26 +07:00
516ef9f38c refactor(api): coerce numeric inputs in library titles schema 2025-11-08 02:39:07 +07:00
2c2812f59e refactor(server): remove 7 days ago last time added. now showing all recently added 2025-11-08 02:37:04 +07:00
463c8311e0 feat(web-client): implement dashboard page 2025-11-08 02:35:20 +07:00
ff8483c5a7 feat(web-client): implement search page 2025-11-07 15:42:47 +07:00
320b811d52 refactor(web-client): improve logic when user is logged in or not 2025-11-07 15:41:56 +07:00
d3d2a6cbab feat(web-client): add sign-up page 2025-11-07 15:40:50 +07:00
672b0e32ff feat(server): add title search endpoint 2025-11-07 13:28:27 +07:00
bf0f928841 feat(server): implement stats router 2025-11-07 02:42:55 +07:00
Kenzuya
ec665399e7 feat(web-client): add preloader, rewrite create playback session to use and improve TitleDetails 2025-11-06 12:51:41 +00:00
Kenzuya
137ad669fb feat(web-client): rewrite frontend implementation using tanstack router 2025-11-06 07:45:37 +00:00
993355da12 feat(client): add HLS video player with playback controls
Add support for streaming media using HLS (HTTP Live Streaming) with a custom player component featuring controls for play/pause, seeking, volume, subtitles, and fullscreen. Includes codec detection, playback session management, error and loading states, and integration with title details page for seamless navigation.
2025-11-03 07:36:58 +00:00
4b4e6c3ead chore(config): disable experimental TypeScript TSGO in VS Code settings 2025-11-03 07:35:32 +00:00
3019dccf03 feat(client): add title details page with media tracks and episodes
- Add TitleDetails component with comprehensive media information
  display
- Add Cast and Crew sections with person cards
- Add MediaTracksSelector for video/audio/subtitle track selection
- Add EpisodeList with season selector for series content
- Add UI components (Alert, Badge) for better user feedback
- Add error parsing utility for HTTP errors
- Add formatRuntime utility for duration formatting
- Add /home/details/:id route for title details page
- Remove unused dev script from web package
2025-11-02 14:57:03 +00:00
dc062753a3 feat(client): add hidePreloader utility function
Add a new utility function to hide the preloader element by adding a "hidden" class, improving user experience during media player interactions.
2025-11-02 09:52:58 +00:00
a8179909d9 feat(auth): add bearer token authentication to API clients
Migrate authentication from cookie-based to Bearer token-based approach.
Update axios HTTP client to include Authorization header with session token.
Modify auth client to use Bearer token in fetch options and handle token storage in localStorage.
2025-11-02 09:52:06 +00:00
1148af17e5 feat(client): add recently added movies section and authentication checks
- Add MovieCard component for displaying movie posters and details
- Add RecentlyAddedSections component with horizontal scrolling for recently added movies
- Add HorizontalScrollList layout component for smooth horizontal scrolling
- Update SignInForm to auto-navigate authenticated users to home
- Update home layout to redirect unauthenticated users to login
- Update auth configuration to use bearer plugin
- Update media API model to default limit to 10
2025-11-02 09:51:10 +00:00
1cebbc4282 feat(client): add no-scrollbar utility class
Adds a new Tailwind CSS utility class to hide scrollbars across browsers, improving UI consistency in scrollable components.
2025-11-02 09:39:32 +00:00
77e5fed73e feat(auth): add redirect to login for unauthenticated users
- Add else clause to navigate to "/login" when authentication check fails
- Ensures users are redirected appropriately in the client app
2025-11-02 07:25:23 +00:00
21e171e920 refactor(client): migrate Header component to use http client 2025-11-02 07:21:13 +00:00
78199d0599 refactor(client): migrate HTTP client from ky to axios
- Replace ky with axios in package.json and update API client implementation
- Update all HTTP requests in client components to use axios syntax
- Adjust model imports in server files to relative paths
- Fix library type labels in onboard setup (Movies -> Movie, Series -> Serie)
2025-11-02 07:19:13 +00:00
a1f21c38e4 feat(server): add dashboard library management API with admin middleware
Add admin middleware to enforce authentication and permission checks for dashboard access.

Implement API models and routes for library CRUD operations, including listing, getting, creating, updating, and deleting libraries with validation and error handling.
2025-11-01 15:32:30 +07:00
d6b4fb128f feat(config): enable TypeScript experimental tsgo compiler 2025-11-01 15:21:49 +07:00
149 changed files with 12609 additions and 133 deletions

View File

@@ -1,62 +1,64 @@
{
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port 3002",
"build": "tsc -b && vite build",
"check-types": "tsc -b",
"lint": "eslint .",
"preview": "vite preview",
"shadcn": "bunx --bun shadcn@latest"
},
"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",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@tailwindcss/vite": "^4.1.16",
"@tanstack/react-form": "^1.23.8",
"@tanstack/react-query": "^5.90.5",
"@trpc/client": "^11.7.1",
"@trpc/tanstack-react-query": "^11.7.1",
"better-auth": "^1.3.34",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"hono": "^4.10.3",
"ky": "^1.13.0",
"lodash": "^4.17.21",
"lucide-react": "^0.548.0",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router": "^7.9.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.16",
"zod": "^4.1.12"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@types/lodash": "^4.17.20",
"@types/node": "^24.6.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.4",
"babel-plugin-react-compiler": "^19.1.0-rc.3",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "^7.1.7"
}
}
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"build": "tsc -b && vite build",
"check-types": "tsc -b",
"lint": "eslint .",
"preview": "vite preview",
"shadcn": "bunx --bun shadcn@latest"
},
"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",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@tailwindcss/vite": "^4.1.16",
"@tanstack/react-form": "^1.23.8",
"@tanstack/react-query": "^5.90.5",
"@trpc/client": "^11.7.1",
"@trpc/tanstack-react-query": "^11.7.1",
"axios": "^1.13.1",
"better-auth": "^1.3.34",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"hono": "^4.10.3",
"lodash": "^4.17.21",
"lucide-react": "^0.548.0",
"media-chrome": "^4.15.1",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-player": "^3.3.3",
"react-router": "^7.9.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.16",
"zod": "^4.1.12"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@types/lodash": "^4.17.20",
"@types/node": "^24.6.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.4",
"babel-plugin-react-compiler": "^19.1.0-rc.3",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "^7.1.7"
}
}

View File

@@ -19,7 +19,7 @@ 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 { http } from "@/lib/api";
import { ThemeModeSwitcher } from "./ThemeModeSwitcher";
import UserMenu from "./UserMenu";
@@ -38,8 +38,8 @@ const NavigationSheetContent = memo(function NavigationSheetContent({
queryKey: ["library-all"],
queryFn: async () => {
const response =
await apiClient.get<Models.API.LibraryAllOutput>("library/all");
return await response.json();
await http.get<Models.API.LibraryAllOutput>("library/all");
return response.data;
},
});

View File

@@ -1,4 +1,5 @@
import { useForm } from "@tanstack/react-form";
import { useEffect } from "react";
import { Link, useNavigate } from "react-router";
// import { Link, useNavigate } from "@tanstack/react-router";
import { toast } from "sonner";
@@ -19,8 +20,12 @@ import { Label } from "./ui/label";
export default function SignInForm() {
const navigate = useNavigate();
const { isPending } = authClient.useSession();
const { isPending, data } = authClient.useSession();
useEffect(() => {
if (data) {
navigate("/home");
}
});
const form = useForm({
defaultValues: {
email: "",

View File

@@ -0,0 +1,42 @@
import { ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import PersonCard from "./PersonCard";
type CastMember = {
id: string;
name: string;
character: string | null;
profilePicture: string | null;
order: number | null;
};
type CastSectionProps = {
cast: CastMember[];
};
export default function Casts({ cast }: CastSectionProps) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-lg">Cast</h3>
<Button
variant="ghost"
size="sm"
className="text-primary hover:text-primary/80"
>
View All <ChevronRight className="ml-1 h-4 w-4" />
</Button>
</div>
<div className="no-scrollbar flex gap-4 overflow-x-auto pb-2">
{cast.map((actor) => (
<PersonCard
key={actor.id}
name={actor.name}
role={actor.character ?? "Unknown"}
imageUrl={actor.profilePicture ?? "/people-placeholder.svg"}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import { ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import PersonCard from "./PersonCard";
type CrewMember = {
id: string;
name: string;
job: string | null;
department: string | null;
profilePicture: string | null;
order: number | null;
};
type CrewSectionProps = {
crew: CrewMember[];
};
export default function CrewSection({ crew }: CrewSectionProps) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-lg">Crew</h3>
<Button
variant="ghost"
size="sm"
className="text-primary hover:text-primary/80"
>
View All <ChevronRight className="ml-1 h-4 w-4" />
</Button>
</div>
<div className="no-scrollbar flex gap-4 overflow-x-auto pb-2">
{crew.map((member) => {
const role = member.job
? member.department
? `${member.job} (${member.department})`
: member.job
: "Unknown";
return (
<PersonCard
key={member.id}
name={member.name}
role={role}
imageUrl={member.profilePicture ?? "/people-placeholder.svg"}
/>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import type { Models } from "@nontara/server/models";
import { Card } from "@/components/ui/card";
type EpisodeCardProps = {
episode: Models.GetSeasonEpisodesOutput["episodes"][number];
};
function formatDuration(seconds: number | null): string {
if (!seconds) return "N/A";
const minutes = Math.floor(seconds / 60);
return `${minutes}m`;
}
export default function EpisodeCard({ episode }: EpisodeCardProps) {
return (
<Card className="cursor-pointer border-border/50 bg-card/50 backdrop-blur-sm transition-colors hover:bg-card/70">
<div className="flex gap-4 p-4">
{/* Episode Number */}
<div className="flex w-12 shrink-0 items-center justify-center">
<span className="font-semibold text-2xl text-muted-foreground">
{episode.episodeNumber}
</span>
</div>
{/* Thumbnail */}
<div className="shrink-0">
<img
src={episode.backgroundUrl ?? "/movie-placeholder.svg"}
alt={episode.title}
className="h-20 w-36 rounded object-cover"
/>
</div>
{/* Episode Info */}
<div className="flex min-w-0 flex-1 flex-col justify-center gap-2">
<h3 className="font-medium text-base">{episode.title}</h3>
<p className="line-clamp-2 text-muted-foreground text-sm leading-relaxed">
{episode.overview || "No description available"}
</p>
</div>
{/* Duration */}
<div className="flex shrink-0 items-center">
<span className="text-muted-foreground text-sm">
{formatDuration(episode.runtimeSeconds)}
</span>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,82 @@
import type { Models } from "@nontara/server/models";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { http } from "@/lib/api";
import EpisodeCard from "./EpisodeCard";
type EpisodesListProps = {
titleId: string;
seasons: Models.SeriesSeason[];
};
export default function EpisodesList({ titleId, seasons }: EpisodesListProps) {
const [selectedSeason, setSelectedSeason] = useState(
seasons[0]?.seasonId || "1",
);
const { data: episodesData, isLoading } = useQuery({
queryKey: ["episode-list", titleId, selectedSeason],
queryFn: async () => {
const response = await http.get<Models.API.TitleSeasonsOutput>(
`title/${titleId}/seasons/${selectedSeason}`,
);
return response.data;
},
});
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="font-semibold text-2xl">Episodes</h2>
<Select value={selectedSeason} onValueChange={setSelectedSeason}>
<SelectTrigger className="w-[180px] border-border/50 bg-card/50">
<SelectValue placeholder="Select season" />
</SelectTrigger>
<SelectContent>
{seasons.map((season) => (
<SelectItem key={season.seasonId} value={season.seasonId}>
{season.title ? (
<>
S{String(season.seasonNumber).padStart(2, "0")}:{" "}
<span className="text-muted-foreground">
{season.title}
</span>
</>
) : (
`Season ${season.seasonNumber}`
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Episodes List */}
<div className="space-y-4">
{isLoading ? (
<div className="flex items-center justify-center p-8">
<p className="text-muted-foreground text-sm">Loading episodes...</p>
</div>
) : episodesData?.episodes.length === 0 ? (
<div className="flex items-center justify-center p-8">
<p className="text-muted-foreground text-sm">
No episodes available
</p>
</div>
) : (
episodesData?.episodes.map((episode) => (
<EpisodeCard key={episode.id} episode={episode} />
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,282 @@
import { findBy } from "@nontara/language-codes";
import type { Models } from "@nontara/server/models";
import { useQuery } from "@tanstack/react-query";
import { FileVideo, Languages, Volume2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Card } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { http } from "@/lib/api";
type MediaTracksSelectorProps = {
titleId: string;
onTracksChange?: (tracks: {
video?: number;
audio?: number;
subtitle?: number;
}) => void;
};
export default function MediaTracksSelector({
titleId,
onTracksChange,
}: MediaTracksSelectorProps) {
const { data, isLoading, error } = useQuery({
queryKey: ["media-tracks", titleId],
queryFn: async () => {
const response = await http.get<Models.API.MediaTracksOutput>(
"/media/tracks",
{
params: {
titleId,
},
},
);
return response.data;
},
});
const [selectedVideo, setSelectedVideo] = useState<string>("");
const [selectedAudio, setSelectedAudio] = useState<string>("");
const [selectedSubtitle, setSelectedSubtitle] = useState<string>("");
// Set default selections when data loads
useEffect(() => {
if (!data || data.length === 0) return;
const mediaItem = data[0];
// Set default video track (first one)
if (mediaItem.tracks.video.length > 0 && !selectedVideo) {
setSelectedVideo(mediaItem.tracks.video[0].streamIndex.toString());
}
// Set default audio track (default or first one)
if (mediaItem.tracks.audio.length > 0 && !selectedAudio) {
const defaultAudio = mediaItem.tracks.audio.find((t) => t.isDefault);
const audioToSelect = defaultAudio || mediaItem.tracks.audio[0];
setSelectedAudio(audioToSelect.streamIndex.toString());
}
// Set default subtitle track if there's a default one
if (mediaItem.tracks.subtitle.length > 0 && !selectedSubtitle) {
const defaultSubtitle = mediaItem.tracks.subtitle.find(
(t) => t.isDefault,
);
if (defaultSubtitle) {
setSelectedSubtitle(defaultSubtitle.streamIndex.toString());
}
}
}, [data, selectedVideo, selectedAudio, selectedSubtitle]);
// Notify parent of track changes
useEffect(() => {
if (onTracksChange) {
onTracksChange({
video: selectedVideo ? Number.parseInt(selectedVideo, 10) : undefined,
audio: selectedAudio ? Number.parseInt(selectedAudio, 10) : undefined,
subtitle:
selectedSubtitle && selectedSubtitle !== "none"
? Number.parseInt(selectedSubtitle, 10)
: undefined,
});
}
}, [selectedVideo, selectedAudio, selectedSubtitle, onTracksChange]);
// Memoized language name lookup to avoid unnecessary re-renders
const languageNames = useMemo(() => {
const cache = new Map<string, string>();
const getLanguageName = (code: string | null): string | null => {
if (!code) return null;
// Check cache first
const cached = cache.get(code);
if (cached) {
return cached;
}
const lowerCode = code.toLowerCase().trim();
// Try to find language by code length
let language: ReturnType<typeof findBy> | undefined;
if (lowerCode.length === 3) {
// Try ISO 639-3 first, then ISO 639-2
language = findBy("3", lowerCode) || findBy("2", lowerCode);
} else if (lowerCode.length === 2) {
// Try ISO 639-1 first, then ISO 639-2
language = findBy("1", lowerCode) || findBy("2", lowerCode);
}
// Get name or fallback to uppercase code
const name = language?.name || code.toUpperCase();
cache.set(code, name);
return name;
};
return getLanguageName;
}, []);
// If loading or error, don't render anything
if (isLoading || error || !data || data.length === 0) {
return null;
}
// Get the first media item (usually there's only one)
const mediaItem = data[0];
const formatVideoTrack = (
track: (typeof mediaItem.tracks.video)[number],
): string => {
const parts = [];
if (track.width && track.height) {
parts.push(`${track.width}x${track.height}`);
}
if (track.codecName) {
parts.push(track.codecName.toUpperCase());
}
return parts.length > 0 ? parts.join(" • ") : `Stream ${track.streamIndex}`;
};
const formatAudioTrack = (
track: (typeof mediaItem.tracks.audio)[number],
): string => {
const parts = [];
if (track.language) {
const langName = languageNames(track.language);
if (langName) {
parts.push(langName);
}
}
if (track.title) {
parts.push(track.title);
}
if (track.channels) {
parts.push(`${track.channels}ch`);
}
if (track.codecName) {
parts.push(track.codecName.toUpperCase());
}
if (track.isDefault) {
parts.push("(Default)");
}
return parts.length > 0 ? parts.join(" • ") : `Stream ${track.streamIndex}`;
};
const formatSubtitleTrack = (
track: (typeof mediaItem.tracks.subtitle)[number],
): string => {
const parts = [];
if (track.language) {
const langName = languageNames(track.language);
if (langName) {
parts.push(langName);
}
}
if (track.title) {
parts.push(track.title);
}
if (track.isForced) {
parts.push("(Forced)");
}
if (track.isDefault) {
parts.push("(Default)");
}
return parts.length > 0 ? parts.join(" • ") : `Stream ${track.streamIndex}`;
};
return (
<div className="space-y-3 sm:space-y-4">
<h2 className="font-semibold text-lg sm:text-xl">Media Tracks</h2>
<Card className="border-border/50 bg-card/50 p-4 backdrop-blur-sm sm:p-6">
<div className="flex flex-col gap-4">
{/* Video Tracks */}
{mediaItem.tracks.video.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 font-medium text-muted-foreground text-sm">
<FileVideo className="h-4 w-4" />
Video Track
</div>
<Select value={selectedVideo} onValueChange={setSelectedVideo}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select video track" />
</SelectTrigger>
<SelectContent>
{mediaItem.tracks.video.map((track) => (
<SelectItem
key={track.streamIndex}
value={track.streamIndex.toString()}
>
{formatVideoTrack(track)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Audio Tracks */}
{mediaItem.tracks.audio.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 font-medium text-muted-foreground text-sm">
<Volume2 className="h-4 w-4" />
Audio Track
</div>
<Select value={selectedAudio} onValueChange={setSelectedAudio}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select audio track" />
</SelectTrigger>
<SelectContent>
{mediaItem.tracks.audio.map((track) => (
<SelectItem
key={track.streamIndex}
value={track.streamIndex.toString()}
>
{formatAudioTrack(track)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Subtitle Tracks */}
{mediaItem.tracks.subtitle.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 font-medium text-muted-foreground text-sm">
<Languages className="h-4 w-4" />
Subtitle Track
</div>
<Select
value={selectedSubtitle}
onValueChange={setSelectedSubtitle}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="No subtitle" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No subtitle</SelectItem>
{mediaItem.tracks.subtitle.map((track) => (
<SelectItem
key={track.streamIndex}
value={track.streamIndex.toString()}
>
{formatSubtitleTrack(track)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { Link } from "react-router";
type MovieCardProps = {
id: string;
imageUrl?: string;
title: string;
description: string | number;
};
export default function MovieCard({
id,
title,
imageUrl,
description,
}: MovieCardProps) {
return (
<div className="flex h-full min-w-36 max-w-36 flex-1 flex-col gap-2 rounded-lg p-0 transition-transform duration-200 hover:scale-105 hover:shadow-lg md:min-w-48 md:max-w-48 md:p-2">
<Link to={`/home/details/${id}`}>
<div className="relative aspect-3/4 w-full overflow-hidden rounded-lg bg-center bg-cover bg-no-repeat">
<img
src={
imageUrl && imageUrl.length > 10
? imageUrl
: "/movie-placeholder.svg"
}
alt={title}
className="rounded-sm"
/>
<div className="absolute right-0 bottom-0 left-0 bg-linear-to-t from-black/70 to-transparent p-2 md:hidden">
<p className="truncate text-foreground text-xs leading-normal">
{title}
</p>
</div>
</div>
</Link>
<div className="hidden md:flex md:flex-col md:gap-1">
<p className="truncate px-1 text-center text-foreground text-sm leading-normal md:text-base">
{title}
</p>
<div className="flex items-center justify-center px-1 text-muted-foreground text-xs">
<span className="text-center">{description}</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
type PersonCardProps = {
name: string;
role: string;
imageUrl: string;
};
export default function PersonCard({ name, role, imageUrl }: PersonCardProps) {
return (
<div className="w-24 shrink-0 space-y-2">
<div className="relative">
<img
src={imageUrl || "/people-placeholder.svg"}
alt={name}
className="h-24 w-24 rounded-full border-2 border-border/50 object-cover"
/>
</div>
<div className="text-center">
<p className="truncate font-medium text-foreground text-sm">{name}</p>
<p className="truncate text-muted-foreground text-xs">{role}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,169 @@
import type { Models } from "@nontara/server/models";
import { useQuery } from "@tanstack/react-query";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { http } from "@/lib/api";
import {
HorizontalScrollList,
type HorizontalScrollListRef,
} from "./layout/HorizontalScrollList";
import MovieCard from "./MovieCard";
const RecentlyAddedSections: React.FC = () => {
// const trpc = useTRPC();
const scrollListRef = React.useRef<HorizontalScrollListRef>(null);
const [canScrollLeft, setCanScrollLeft] = React.useState(false);
const [canScrollRight, setCanScrollRight] = React.useState(false);
const handleScrollableChange = React.useCallback(
(left: boolean, right: boolean) => {
setCanScrollLeft(left);
setCanScrollRight(right);
},
[],
);
const {
data: recentlyAddedMovies,
isLoading,
error,
refetch,
} = useQuery({
queryKey: ["recentlyAddedMovies"],
queryFn: async () => {
const response = await http.get<Models.API.RecentlyAddedOutput>(
"/media/recentlyAdded",
{
params: { limit: 10 },
},
);
return response.data;
},
});
// Loading skeleton
if (isLoading) {
return (
<div className="w-full rounded-lg p-6 shadow-sm">
<div className="pb-2 sm:pb-3">
<h3 className="font-semibold text-base sm:text-lg md:text-xl">
Recently Added
</h3>
</div>
<div className="px-2 sm:px-4">
<div className="flex space-x-2 overflow-hidden">
{Array.from({ length: 5 }).map((_, index) => (
<div
key={`skeleton-${Date.now()}-${index}`}
className="flex-none"
style={{ width: "150px" }}
>
<Skeleton className="aspect-2/3 w-full rounded-xl" />
<Skeleton className="mt-2 h-4 w-full" />
<Skeleton className="mt-1 h-3 w-3/4" />
</div>
))}
</div>
</div>
</div>
);
}
// Error state with retry
if (error) {
return (
<div className="w-full rounded-lg p-6 shadow-sm">
<div className="pb-2 sm:pb-3">
<h3 className="font-semibold text-base sm:text-lg md:text-xl">
Recently Added
</h3>
</div>
<div>
<div className="flex flex-col items-center justify-center py-8 text-center">
<p className="mb-4 text-muted-foreground">
Failed to load recently added movies
</p>
<button
type="button"
onClick={() => refetch()}
className="rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/90"
>
Try Again
</button>
</div>
</div>
</div>
);
}
// Empty state
if (!recentlyAddedMovies || recentlyAddedMovies.length === 0) {
return (
<div className="w-full rounded-lg p-6 shadow-sm">
<div className="pb-2 sm:pb-3">
<h3 className="font-semibold text-base sm:text-lg md:text-xl">
Recently Added
</h3>
</div>
<div>
<div className="flex items-center justify-center py-8">
<p className="text-muted-foreground">No recently added movies</p>
</div>
</div>
</div>
);
}
return (
<div className="w-full rounded-lg p-6 shadow-sm">
<div className="pb-2 sm:pb-3">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-base sm:text-lg md:text-xl">
Recently Added
</h3>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => scrollListRef.current?.scrollLeft()}
aria-label="Scroll left"
disabled={!canScrollLeft}
>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => scrollListRef.current?.scrollRight()}
aria-label="Scroll right"
disabled={!canScrollRight}
>
</Button>
</div>
</div>
</div>
<div className="overflow-hidden">
<HorizontalScrollList
ref={scrollListRef}
className="w-full"
onScrollableChange={handleScrollableChange}
>
{recentlyAddedMovies.map((movie) => (
<div key={movie.id} className="w-36 flex-none md:w-48">
<MovieCard
id={movie.id}
imageUrl={movie.poster ?? undefined}
title={movie.title}
description={movie.year || ""}
/>
</div>
))}
</HorizontalScrollList>
</div>
</div>
);
};
export default RecentlyAddedSections;

View File

@@ -0,0 +1,44 @@
import { ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import MovieCard from "./MovieCard";
type SimilarTitle = {
id: string;
title: string;
year: number | null;
poster: string | null;
};
type SimilarTitlesSectionProps = {
titles: SimilarTitle[];
};
export default function SimilarTitlesSection({
titles,
}: SimilarTitlesSectionProps) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-lg">Similar Titles</h3>
<Button
variant="ghost"
size="sm"
className="text-primary hover:text-primary/80"
>
View All <ChevronRight className="ml-1 h-4 w-4" />
</Button>
</div>
<div className="no-scrollbar flex gap-4 overflow-x-auto pb-2">
{titles.map((similarTitle) => (
<MovieCard
key={similarTitle.id}
id={similarTitle.id}
imageUrl={similarTitle.poster || undefined}
title={similarTitle.title}
description={similarTitle.year ?? "Year unknown"}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,220 @@
import {
Bookmark,
Calendar,
Clock,
Film,
Play,
Share2,
Star,
} from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { formatRuntime } from "@/lib/utils";
import type { GetTitleDetailsOutput } from "../../../../server/src/models/trpc/titles";
import Casts from "./Casts";
import Crews from "./Crews";
import EpisodesList from "./EpisodeList";
import MediaTracksSelector from "./MediaTracksSelector";
import SimilarTitlesSection from "./SimilarTitleSection";
type TitleDetailsProps = {
data: GetTitleDetailsOutput;
};
export default function TitleDetails({ data }: TitleDetailsProps) {
const navigate = useNavigate();
const [selectedTracks, setSelectedTracks] = useState<{
video?: number;
audio?: number;
subtitle?: number;
}>({});
const getEndTime = (runtimeSeconds: number) => {
const endTime = new Date(Date.now() + runtimeSeconds * 1000);
return endTime.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
});
};
const handlePlay = () => {
const searchParams = new URLSearchParams();
if (selectedTracks.audio !== undefined) {
searchParams.append("asi", selectedTracks.audio.toString());
}
if (selectedTracks.subtitle !== undefined) {
searchParams.append("ssi", selectedTracks.subtitle.toString());
}
navigate({
pathname: `/watch/${data.id}`,
search: searchParams.toString(),
});
};
return (
<div className="relative min-h-[calc(100vh-4rem)]">
{/* Backdrop with gradient overlay */}
<div className="absolute inset-0 h-[50vh] sm:h-[60vh] lg:h-[80vh]">
{data.backdrop && (
<img
src={data.backdrop}
alt={`${data.title} backdrop`}
className="h-full w-full object-cover"
/>
)}
<div className="absolute inset-0 bg-linear-to-t from-background via-background/80 to-background/20" />
<div className="absolute inset-0 bg-linear-to-r from-background via-transparent to-background/60" />
</div>
{/* Content */}
<div className="relative z-10">
{/* Main Content */}
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-12">
<div className="grid grid-cols-1 items-start gap-6 sm:gap-8 lg:grid-cols-3 lg:gap-12">
{/* Poster */}
<div className="mx-auto w-full max-w-[200px] sm:max-w-xs md:max-w-sm lg:col-span-1 lg:max-w-none">
<Card className="overflow-hidden border-border/50 bg-card/50 p-0 backdrop-blur-sm">
<img
src={data.poster ?? "/movie-placeholder.svg"}
alt={`${data.title} poster`}
className="w-full object-contain"
/>
</Card>
</div>
{/* Movie Info */}
<div className="min-w-0 space-y-4 sm:space-y-6 lg:col-span-2">
{/* Title and Basic Info */}
<div className="space-y-2 sm:space-y-3">
<h1 className="wrap-break-word text-balance font-bold text-2xl leading-tight sm:text-3xl md:text-4xl lg:text-5xl xl:text-6xl">
{data.title}
</h1>
{data.originalTitle && data.originalTitle !== data.title && (
<div className="inline-block rounded-md bg-muted px-3 py-1 text-muted-foreground text-sm">
{data.originalTitle}
</div>
)}
<div className="flex flex-wrap items-center gap-2 text-muted-foreground text-xs sm:gap-3 sm:text-sm">
{data.year && (
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{data.year}
</span>
)}
{data.type === "MOVIE" && data.runtimeSeconds && (
<>
<span className="flex items-center gap-1">
<Clock className="h-4 w-4" />
{formatRuntime(data.runtimeSeconds)}
</span>
<span className="flex items-center gap-1">
Ends at {getEndTime(data.runtimeSeconds)}
</span>
</>
)}
{data.type === "SERIES" && (
<span className="flex items-center gap-1">
<Film className="h-4 w-4" />
{data.seasonsCount}{" "}
{data.seasonsCount === 1 ? "Season" : "Seasons"} {" "}
{data.episodesCount}{" "}
{data.episodesCount === 1 ? "Episode" : "Episodes"}
</span>
)}
{data.rating && (
<div className="flex items-center gap-1">
<Star className="h-4 w-4 fill-primary text-primary" />
<span className="font-medium text-foreground">
{data.rating.toFixed(1)}
</span>
</div>
)}
</div>
<div className="flex flex-wrap gap-1.5 sm:gap-2">
{data.genres.map((genre) => (
<Badge
key={genre.id}
variant="secondary"
className="bg-secondary/50 text-secondary-foreground text-xs sm:text-sm"
>
{genre.name}
</Badge>
))}
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:gap-3">
<Button
size="lg"
className="w-full bg-primary text-primary-foreground hover:bg-primary/90 sm:w-auto"
onClick={handlePlay}
>
<Play className="mr-2 h-5 w-5" />
Play
</Button>
<Button
variant="outline"
size="lg"
className="w-full sm:w-auto"
>
<Bookmark className="mr-2 h-5 w-5" />
Watchlist
</Button>
<Button
variant="outline"
size="lg"
className="w-full sm:w-auto"
>
<Share2 className="mr-2 h-5 w-5" />
Share
</Button>
</div>
{/* Synopsis */}
<div className="space-y-2 sm:space-y-3">
<h2 className="font-semibold text-lg sm:text-xl">Synopsis</h2>
<p className="text-pretty text-muted-foreground text-sm leading-relaxed sm:text-base">
{data.synopsis ?? "No synopsis available."}
</p>
</div>
{/* Media Tracks - Only for Movies */}
{data.type === "MOVIE" && (
<MediaTracksSelector
titleId={data.id}
onTracksChange={setSelectedTracks}
/>
)}
{/* Episodes - Only for Series */}
{data.type === "SERIES" && data.seasons.length > 0 && (
<EpisodesList titleId={data.id} seasons={data.seasons} />
)}
{/* Cast */}
{data.cast.length > 0 && <Casts cast={data.cast} />}
{/* Crew */}
{data.crew.length > 0 && <Crews crew={data.crew} />}
{/* Similar Titles */}
{data.similarTitles.length > 0 && (
<SimilarTitlesSection titles={data.similarTitles} />
)}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,92 @@
import * as React from "react";
import { cn } from "@/lib/utils";
interface HorizontalScrollListProps {
children: React.ReactNode;
className?: string;
scrollStep?: number;
onScrollableChange?: (
canScrollLeft: boolean,
canScrollRight: boolean,
) => void;
}
export interface HorizontalScrollListRef {
scrollLeft: () => void;
scrollRight: () => void;
}
export const HorizontalScrollList = React.forwardRef<
HorizontalScrollListRef,
HorizontalScrollListProps
>(({ children, className, scrollStep = 200, onScrollableChange }, ref) => {
const scrollRef = React.useRef<HTMLDivElement>(null);
const checkScrollable = React.useCallback(() => {
if (!scrollRef.current || !onScrollableChange) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
const canScrollLeft = scrollLeft > 0;
const canScrollRight = scrollLeft + clientWidth < scrollWidth;
onScrollableChange(canScrollLeft, canScrollRight);
}, [onScrollableChange]);
const scrollLeft = React.useCallback(() => {
if (!scrollRef.current) return;
scrollRef.current.scrollBy({ left: -scrollStep, behavior: "smooth" });
}, [scrollStep]);
const scrollRight = React.useCallback(() => {
if (!scrollRef.current) return;
scrollRef.current.scrollBy({ left: scrollStep, behavior: "smooth" });
}, [scrollStep]);
React.useEffect(() => {
checkScrollable();
}, [checkScrollable]);
React.useEffect(() => {
const element = scrollRef.current;
if (!element) return;
const handleScroll = () => {
checkScrollable();
};
const handleResize = () => {
checkScrollable();
};
element.addEventListener("scroll", handleScroll);
window.addEventListener("resize", handleResize);
return () => {
element.removeEventListener("scroll", handleScroll);
window.removeEventListener("resize", handleResize);
};
}, [checkScrollable]);
React.useImperativeHandle(
ref,
() => ({
scrollLeft,
scrollRight,
}),
[scrollLeft, scrollRight],
);
return (
<div className={cn("w-full", className)}>
<section
ref={scrollRef}
className="scrollbar-hide no-scrollbar flex gap-2 overflow-x-auto overflow-y-hidden scroll-smooth"
aria-label="Scrollable content"
>
{children}
</section>
</div>
);
});
HorizontalScrollList.displayName = "HorizontalScrollList";

View File

@@ -0,0 +1,97 @@
import {
MediaCaptionsButton,
MediaControlBar,
MediaController,
MediaFullscreenButton,
MediaMuteButton,
MediaPlayButton,
MediaSeekBackwardButton,
MediaSeekForwardButton,
MediaTimeDisplay,
MediaTimeRange,
MediaVolumeRange,
} from "media-chrome/react";
import { useMemo } from "react";
import ReactPlayer from "react-player";
import { PlayerErrorState } from "@/components/player/states/PlayerErrorState";
import { PlayerLoadingState } from "@/components/player/states/PlayerLoadingState";
import { detectCodecSupport } from "@/utils/codec-detection";
import { usePlaybackSession } from "./hooks/usePlaybackSession";
type SelectedTracks = {
audio?: number;
subtitle?: number;
};
interface HLSPlayerProps {
titleId: string;
episodeId?: string;
selectedTracks?: SelectedTracks;
}
export function HLSPlayer({
titleId,
episodeId,
selectedTracks,
}: HLSPlayerProps) {
const supportedCodecs = useMemo(() => detectCodecSupport(), []);
const { isLoading, isError, data, error } = usePlaybackSession({
titleId,
episodeId,
supportedCodecs,
selectedTracks,
});
if (isLoading) {
return <PlayerLoadingState />;
}
if (isError) {
return <PlayerErrorState error={new Error(error.message)} />;
}
if (!data) {
return <PlayerLoadingState />;
}
return (
<MediaController
style={{
width: "100%",
aspectRatio: "16/9",
}}
>
<ReactPlayer
slot="media"
src={data.playlistUrl}
config={{
hls: {
enableWorker: true,
startPosition: 0,
autoStartLoad: true,
},
}}
playing
playsInline
controls={false}
style={{
width: "100%",
height: "100%",
// "--controls": "none",
}}
/>
<MediaControlBar>
<MediaPlayButton />
<MediaSeekBackwardButton seekOffset={10} />
<MediaSeekForwardButton seekOffset={10} />
<MediaTimeRange />
<MediaTimeDisplay showDuration />
<MediaMuteButton />
<MediaVolumeRange />
<MediaCaptionsButton />
<MediaFullscreenButton />
</MediaControlBar>
</MediaController>
);
}

View File

@@ -0,0 +1,95 @@
import type { Models } from "@nontara/server/models";
import { useMutation } from "@tanstack/react-query";
import { useEffect, useEffectEvent, useRef } from "react";
import { toast } from "sonner";
import { http } from "@/lib/api";
import type { AudioCodec, VideoCodec } from "@/utils/codec-detection";
interface UsePlaybackSessionOptions {
titleId?: string;
episodeId?: string;
supportedCodecs: {
video: VideoCodec[];
audio: AudioCodec[];
};
selectedTracks?: {
audio?: number;
subtitle?: number;
};
maxResolution?: "480p" | "720p" | "1080p" | "2160p";
}
export function usePlaybackSession(options: UsePlaybackSessionOptions) {
const sessionId = useRef<string>(
sessionStorage.getItem("activePlaybackSessionId"),
);
const { mutate, isError, data, error, isPending } = useMutation({
mutationFn: async (variables: Models.API.PlaybackCreateInput) => {
const response = await http.post<Models.API.PlaybackCreateOutput>(
"/playback/create",
variables,
);
return response.data;
},
onError: (error) => {
toast.error(`Failed to create playback session: ${error.message}`);
},
onSuccess: (data) => {
sessionId.current = data.sessionId;
sessionStorage.setItem("activePlaybackSessionId", data.sessionId);
},
});
const { mutate: mutateStopSession } = useMutation({
mutationFn: async (variables: Models.API.PlaybackStopInput) => {
const response = await http.delete<Models.API.PlaybackStopOutput>(
"playback/stop",
{
params: variables,
},
);
return response.data;
},
});
const onCreateSession = useEffectEvent(() => {
mutate({
titleId: options.titleId,
episodeId: options.episodeId,
supportedVideoCodecs: options.supportedCodecs.video,
supportedAudioCodecs: options.supportedCodecs.audio,
subtitleStreamIndex: options.selectedTracks?.subtitle,
maxResolution: options.maxResolution || "1080p",
burnInSubtitle: false,
});
});
const onDisposed = useEffectEvent(() => {
if (!sessionId.current) return;
mutateStopSession({
sessionId: sessionId.current,
});
sessionStorage.removeItem("activePlaybackSessionId");
});
useEffect(() => {
if (sessionId.current) return;
onCreateSession();
return () => {
onDisposed();
};
}, []);
if (isError) {
return {
isLoading: isPending,
isError: isError,
error: error,
};
}
return {
isLoading: isPending,
isError: isError,
error: error,
data: data,
};
}

View File

@@ -0,0 +1,33 @@
import { useNavigate } from "@tanstack/react-router";
import { AlertCircle, ArrowLeft } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
interface PlayerErrorStateProps {
error: Error;
}
export function PlayerErrorState({ error }: PlayerErrorStateProps) {
const navigate = useNavigate();
return (
<div className="flex h-screen w-full items-center justify-center bg-black p-4">
<div className="w-full max-w-md space-y-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Playback Error</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
</Alert>
<Button
variant="outline"
className="w-full"
onClick={() => navigate({ to: "/home" })}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Home
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { Loader2 } from "lucide-react";
export function PlayerLoadingState() {
return (
<div className="flex h-screen w-full items-center justify-center bg-black">
<div className="text-center">
<Loader2 className="mx-auto h-12 w-12 animate-spin text-primary" />
<p className="mt-4 text-muted-foreground">Preparing playback...</p>
</div>
</div>
);
}

View File

@@ -1,14 +1,11 @@
import type { Models } from "@nontara/server/models";
// import type { OnboardFolderOutput } from "@nontara/server/src/models";
import { useMutation } from "@tanstack/react-query";
import { ChevronRightIcon, FolderIcon, HomeIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { apiClient } from "@/lib/api";
// import { useTRPC } from "@/utils/trpc";
import { http } from "@/lib/api";
interface FolderExplorerProps {
onPathSelect: (path: string) => void;
@@ -26,20 +23,18 @@ export function FolderExplorer({
return [];
});
const [folderContents, setFolderContents] = useState<
Models.OnboardFolderOutput[]
Models.API.OnboardFolderOutput[]
>([]);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// const trpc = useTRPC();
const folderExplorerMutation = useMutation({
mutationFn: async (variables: Models.OnboardFolderInput) => {
const response = await apiClient.post<Models.OnboardFolderOutput[]>(
mutationFn: async (variables: Models.API.OnboardFolderInput) => {
const response = await http.post<Models.API.OnboardFolderOutput[]>(
"onboard/folder",
{
json: variables,
},
variables,
);
return await response.json();
return response.data;
},
});

View File

@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -118,3 +118,7 @@
@apply bg-background text-foreground;
}
}
@utility no-scrollbar {
@apply [scrollbar-width:none] [&::-webkit-scrollbar]:hidden;
}

View File

@@ -1,4 +1,24 @@
import ky from "ky";
export const apiClient = ky.create({
prefixUrl: new URL(import.meta.env.VITE_SERVER_URL as string),
import axios from "axios";
export const http = axios.create({
baseURL: import.meta.env.VITE_SERVER_URL as string,
headers: {
Authorization: localStorage.getItem("session_token")
? `Bearer ${localStorage.getItem("session_token")}`
: "",
},
});
interface HTTPErrorResponse<C = null> {
error: {
message: string;
context?: C;
}
}
export function parseHTTPError(error: unknown): HTTPErrorResponse {
if (axios.isAxiosError(error) && error.response) {
return error.response.data as HTTPErrorResponse;
}
return { error: { message: "Unknown error" } };
}

View File

@@ -7,7 +7,6 @@ import {
import { adminClient, usernameClient } from "better-auth/client/plugins";
import { createAccessControl } from "better-auth/plugins/access";
import { createAuthClient } from "better-auth/react";
import { reactStartCookies } from "better-auth/react-start";
const ac = createAccessControl(statement);
@@ -23,6 +22,17 @@ export const authClient = createAuthClient({
user: userRole,
},
}),
reactStartCookies(),
],
fetchOptions: {
auth: {
type: "Bearer",
token: () => localStorage.getItem("session_token") || "",
},
onSuccess: (ctx) => {
const authToken = ctx.response.headers.get("set-auth-token");
if (authToken) {
localStorage.setItem("session_token", authToken);
}
},
},
});

View File

@@ -1,6 +1,26 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs));
}
export function hidePreloader() {
const preloader = document.getElementById("preloader");
if (preloader) {
preloader.classList.add("hidden");
}
}
export function formatRuntime(seconds: number | null): string {
if (!seconds) return "N/A";
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}

View File

@@ -2,6 +2,7 @@ import { createRoot } from "react-dom/client";
import "./index.css";
import { BrowserRouter, Route, Routes } from "react-router";
import DefaultLayout from "./components/DefaultLayout.tsx";
import { TitleDetailsPage } from "./routes/home/details/titleId.tsx";
import { HomeIndexPage } from "./routes/home/index.tsx";
import { HomeLayout } from "./routes/home/layout.tsx";
import IndexPage from "./routes/index.tsx";
@@ -12,6 +13,7 @@ import { OnboardLanguagePage } from "./routes/onboard/language.tsx";
import { OnboardLayout } from "./routes/onboard/layout.tsx";
import { OnboardSetupPage } from "./routes/onboard/setup.tsx";
import { SignupPage } from "./routes/signup.tsx";
import WatchTitlePage from "./routes/watch/id.tsx";
const root = createRoot(document.getElementById("root")!);
@@ -32,7 +34,9 @@ root.render(
</Route>
<Route path="home" element={<HomeLayout />}>
<Route index element={<HomeIndexPage />} />
<Route path="details/:id" element={<TitleDetailsPage />} />
</Route>
<Route path="watch/:id" element={<WatchTitlePage />} />
</Route>
</Routes>
</BrowserRouter>,

View File

@@ -0,0 +1,87 @@
import type { Models } from "@nontara/server/models";
import { useQuery } from "@tanstack/react-query";
import { AlertCircle, ArrowLeft, Loader2 } from "lucide-react";
import { Link, useParams } from "react-router";
import TitleDetails from "@/components/nontara/TitleDetails";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { http, parseHTTPError } from "@/lib/api";
export function TitleDetailsPage() {
const params = useParams<{ id: string }>();
const {
isLoading,
data,
error: httpError,
isError,
} = useQuery({
queryKey: ["titleDetails", params.id],
queryFn: async () => {
// Fetch title details logic here
const response = await http.get<Models.API.TitleDetailsOutput>(
`title/details/${params.id}`,
);
return response.data;
},
});
// Loading state
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="text-center">
<Loader2 className="mx-auto h-12 w-12 animate-spin text-primary" />
<p className="mt-4 text-muted-foreground">Loading title details...</p>
</div>
</div>
);
}
// Error state
if (isError) {
const { error } = parseHTTPError(httpError);
return (
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-12">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
</Alert>
<div className="mt-6">
<Link to="/home">
<Button variant="outline" className="w-full sm:w-auto">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Home
</Button>
</Link>
</div>
</div>
);
}
// Success state
if (!data) {
return (
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-12">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle>No Data</AlertTitle>
<AlertDescription>
No title data was returned from the server.
</AlertDescription>
</Alert>
<div className="mt-6">
<Link to="/home">
<Button variant="outline" className="w-full sm:w-auto">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Home
</Button>
</Link>
</div>
</div>
);
}
return <TitleDetails data={data} />;
}

View File

@@ -1,3 +1,9 @@
import RecentlyAddedSections from "@/components/nontara/RecentlyAddedSections";
export function HomeIndexPage() {
return <div className="container mx-auto">Home page</div>;
return (
<div className="container mx-auto">
<RecentlyAddedSections />
</div>
);
}

View File

@@ -1,7 +1,27 @@
import { Outlet } from "react-router";
import { useEffect, useEffectEvent } from "react";
import { Outlet, useNavigate } from "react-router";
import Header from "@/components/Header";
import { authClient } from "@/lib/auth-client";
export function HomeLayout() {
const navigate = useNavigate();
const { isPending, data, error } = authClient.useSession();
const onNavigateIfNotAuthenticated = useEffectEvent(() => {
if (isPending) {
return;
}
if (error || !data) {
navigate("/login");
}
});
useEffect(() => {
if (isPending) {
return;
}
if (error || !data) {
onNavigateIfNotAuthenticated();
}
}, [isPending, data, error]);
return (
<Header>
<main className="flex flex-1 flex-col gap-6">

View File

@@ -4,15 +4,15 @@ import { useEffect, useEffectEvent } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { apiClient } from "@/lib/api";
import { http } from "@/lib/api";
export default function IndexPage() {
const navigate = useNavigate();
const { mutate: mutateOnboardStatus, isPending } = useMutation({
mutationFn: async () => {
const response =
await apiClient.get<Models.OnboardStatusOutput>("onboard/status");
return await response.json();
await http.get<Models.API.OnboardStatusOutput>("onboard/status");
return response.data;
},
onSuccess(data) {
if (data.needsOnboarding) {
@@ -21,6 +21,8 @@ export default function IndexPage() {
if (preloader) {
preloader.classList.add("hidden");
}
} else {
navigate("/login");
}
},
retry: 1,

View File

@@ -8,21 +8,19 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useOnboard } from "@/hooks/useOnboard";
import { apiClient } from "@/lib/api";
import { http } from "@/lib/api";
export function OnboardCompletePage() {
const onboard = useOnboard();
const navigate = useNavigate();
const [data] = useState(() => onboard.dump());
const onboardCompletedMutation = useMutation({
mutationFn: async (variables: Models.OnboardCompletedInput) => {
const response = await apiClient.post<Models.OnboardCompletedOutput>(
"onboard/complete",
{
json: variables,
},
mutationFn: async (variables: Models.API.OnboardCompletedInput) => {
const response = await http.post<Models.API.OnboardCompletedOutput>(
"/onboard/complete",
variables,
);
return await response.json();
return response.data;
},
});
const handleCompletionOnboard = async () => {

View File

@@ -1,5 +1,4 @@
import type { Models } from "@nontara/server/models";
// import { createFileRoute, useNavigate } from "@tanstack/react-router";
import {
FilmIcon,
FolderIcon,
@@ -34,8 +33,8 @@ const PAGE_DESCRIPTION =
"Point Nontara to the folders you want us to manage so we can keep them organised and up to date.";
const LIBRARY_TYPE_LABEL: Record<Models.LibraryData["type"], string> = {
MOVIE: "Movies",
SERIES: "Series",
MOVIE: "Movie",
SERIES: "Serie",
};
function createId() {

View File

@@ -0,0 +1,41 @@
import { useMemo } from "react";
import { useLocation, useParams } from "react-router";
import { z } from "zod";
import { HLSPlayer } from "@/components/player/HLSPlayer";
const watchSearchSchema = z.object({
asi: z.coerce.number(),
ssi: z.coerce.number().optional(), // subtitleStreamIndex
});
export default function WatchTitlePage() {
const params = useParams<{ id: string }>();
const { search } = useLocation();
const parsed = useMemo(() => {
const searchParams = new URLSearchParams(search);
return watchSearchSchema.safeParse(Object.fromEntries(searchParams));
}, [search]);
if (!params.id) {
return <div>Title ID is missing.</div>;
}
if (!parsed.success) {
return <div>Invalid playback parameters.</div>;
}
console.log("Rendering watch page with search params:", parsed.data);
return (
<div className="min-h-screen w-full bg-black">
<HLSPlayer
titleId={params.id}
selectedTracks={{
audio: parsed.data.asi,
subtitle: parsed.data.ssi,
}}
/>
</div>
);
}

View File

@@ -0,0 +1,65 @@
export type VideoCodec = "H264" | "H265" | "VP9" | "AV1";
export type AudioCodec = "AAC" | "MP3" | "OPUS" | "VORBIS";
export interface CodecSupport {
video: VideoCodec[];
audio: AudioCodec[];
}
export function detectCodecSupport(): CodecSupport {
const video = document.createElement("video");
const videoCodecs: VideoCodec[] = [];
const audioCodecs: AudioCodec[] = [];
// Test video codecs
if (video.canPlayType('video/mp4; codecs="avc1.42E01E"') !== "") {
videoCodecs.push("H264");
}
if (video.canPlayType('video/mp4; codecs="hev1.1.6.L93.B0"') !== "") {
videoCodecs.push("H265");
}
if (video.canPlayType('video/webm; codecs="vp9"') !== "") {
videoCodecs.push("VP9");
}
if (video.canPlayType('video/mp4; codecs="av01.0.04M.08"') !== "") {
videoCodecs.push("AV1");
}
// Fallback: if no codecs detected, add H264 (most compatible)
if (videoCodecs.length === 0) {
videoCodecs.push("H264");
}
// Test audio codecs
if (video.canPlayType('audio/mp4; codecs="mp4a.40.2"') !== "") {
audioCodecs.push("AAC");
}
if (video.canPlayType("audio/mpeg") !== "") {
audioCodecs.push("MP3");
}
if (video.canPlayType('audio/webm; codecs="opus"') !== "") {
audioCodecs.push("OPUS");
}
if (video.canPlayType('audio/ogg; codecs="vorbis"') !== "") {
audioCodecs.push("VORBIS");
}
// Fallback: if no audio codecs detected, add AAC (most compatible)
if (audioCodecs.length === 0) {
audioCodecs.push("AAC");
}
return { video: videoCodecs, audio: audioCodecs };
}
export function formatTime(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
}
return `${minutes}:${secs.toString().padStart(2, "0")}`;
}

View File

@@ -1,6 +1,6 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { admin, username } from "better-auth/plugins";
import { admin, bearer, username } from "better-auth/plugins";
import { reactStartCookies } from "better-auth/react-start";
import { database } from "../db";
import * as schema from "../db/schema/auth";
@@ -38,7 +38,7 @@ const auth = betterAuth({
defaultRole: "user",
}),
username(),
reactStartCookies(),
bearer(),
],
});

View File

@@ -7,9 +7,11 @@ import { appRouter } from "@/trpc";
import { createContext } from "@/trpc/context";
import { auth } from "./lib/auth";
import type { HonoEnv } from "./lib/types";
import { DashboardRouter } from "./routes/dashboard";
import { LibraryRouter } from "./routes/library";
import { MediaRouter } from "./routes/media";
import { OnboardRouter } from "./routes/onboard";
import { PlaybackRouter } from "./routes/playback";
import { TitleRouter } from "./routes/title";
import { createServiceContainer } from "./services/setup";
import { formatBytes } from "./utils/formatter";
@@ -54,7 +56,8 @@ const app = factory
.route("/title", TitleRouter)
.route("/media", MediaRouter)
.route("/library", LibraryRouter)
.route("/playback", PlaybackRouter)
.route("/dashboard", DashboardRouter)
.get("/", (c) => {
return c.text("OK");
})

View File

@@ -0,0 +1,36 @@
import { createMiddleware } from "hono/factory";
import { auth } from "@/lib/auth";
import { createHTTPException } from "@/lib/errors";
import type { ExtendHonoEnv } from "@/lib/types";
type AdminEnv = ExtendHonoEnv<{
session: Exclude<Awaited<ReturnType<typeof auth.api.getSession>>, null>;
user: Exclude<Awaited<ReturnType<typeof auth.api.getUser>>, null>;
}>;
export const adminMiddleware = createMiddleware<AdminEnv>(async (c, next) => {
const session = await auth.api.getSession({
headers: c.req.raw.headers,
});
if (!session) {
throw createHTTPException(401, "You are not authorized");
}
c.set("session", session);
c.set("user", session.user);
const hasPermission = await auth.api.userHasPermission({
body: {
userId: session.user.id,
permissions: {
dashboard: ["access"],
},
},
});
if (!hasPermission) {
throw createHTTPException(
403,
"You do not have permission to access the dashboard",
);
}
return await next();
});

View File

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

View File

@@ -0,0 +1,95 @@
import { z } from "zod";
const LIBRARY_TYPES = ["MOVIE", "SERIES"] as const;
// List all libraries
export const LibraryListInput = z.void();
export type LibraryListInput = z.infer<typeof LibraryListInput>;
export const LibraryListOutput = z.array(
z.object({
id: z.number(),
name: z.string(),
type: z.enum(LIBRARY_TYPES),
path: z.string(),
primaryLanguage: z.string(),
fallbackLanguage: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
}),
);
export type LibraryListOutput = z.infer<typeof LibraryListOutput>;
// Get a library by ID
export const LibraryGetInput = z.object({
id: z.number().min(1),
});
export type LibraryGetInput = z.infer<typeof LibraryGetInput>;
export const LibraryGetOutput = z.object({
id: z.number(),
name: z.string(),
type: z.enum(LIBRARY_TYPES),
path: z.string(),
primaryLanguage: z.string(),
fallbackLanguage: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
});
export type LibraryGetOutput = z.infer<typeof LibraryGetOutput>;
// Create a new library
export const LibraryCreateInput = z.object({
name: z.string().min(1).max(255),
type: z.enum(LIBRARY_TYPES),
path: z.string().min(1).max(255),
primaryLanguage: z.string().min(1).max(255),
fallbackLanguage: z.string().min(1).max(255),
});
export type LibraryCreateInput = z.infer<typeof LibraryCreateInput>;
export const LibraryCreateOutput = z.object({
success: z.boolean(),
message: z.string(),
data: LibraryGetOutput,
});
export type LibraryCreateOutput = z.infer<typeof LibraryCreateOutput>;
// Update a library
export const LibraryUpdateInputParam = z.object({
id: z.number(),
});
export type LibraryUpdateInputParam = z.infer<typeof LibraryUpdateInputParam>;
export const LibraryUpdateInput = z.object({
name: z.string().min(1).max(255),
type: z.enum(LIBRARY_TYPES),
path: z.string().min(1).max(255),
primaryLanguage: z.string().min(1).max(255),
fallbackLanguage: z.string().min(1).max(255),
});
export type LibraryUpdateInput = z.infer<typeof LibraryUpdateInput>;
export const LibraryUpdateOutput = z.object({
id: z.number(),
name: z.string(),
type: z.enum(LIBRARY_TYPES),
path: z.string(),
primaryLanguage: z.string(),
fallbackLanguage: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
});
export type LibraryUpdateOutput = z.infer<typeof LibraryUpdateOutput>;
// Delete a library
export const LibraryDeleteInput = z.object({
id: z.number().min(1),
});
export type LibraryDeleteInput = z.infer<typeof LibraryDeleteInput>;
export const LibraryDeleteOutput = z.object({
success: z.boolean(),
message: z.string().optional(),
});
export type LibraryDeleteOutput = z.infer<typeof LibraryDeleteOutput>;

View File

@@ -0,0 +1,10 @@
import z from "zod";
export const StatsOverviewOutput = z.object({
totalUsers: z.number(),
totalMovies: z.number(),
totalSeries: z.number(),
totalEpisodes: z.number(),
totalLibraries: z.number(),
});
export type StatsOverviewOutput = z.infer<typeof StatsOverviewOutput>;

View File

@@ -1,2 +1,6 @@
export * from "./dashboard";
export * from "./library";
export * from "./media";
export * from "./onboard";
export * from "./playback";
export * from "./title";

View File

@@ -1,9 +1,9 @@
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),
libraryId: z.coerce.number(),
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(20),
});
export const LibraryTitlesOutput = z.object({

View File

@@ -1,5 +1,5 @@
import { z } from "zod";
import { MediaItemSchema } from "@/models/internals/media";
import { MediaItemSchema } from "../internals/media";
import {
AudioTrackSchema,
SubtitleTrackSchema,
@@ -7,7 +7,7 @@ import {
} from "../internals/tracks";
export const RecentlyAddedInput = z.object({
limit: z.number(),
limit: z.coerce.number().default(10),
});
export type RecentlyAddedInput = z.infer<typeof RecentlyAddedInput>;

View File

@@ -1,6 +1,6 @@
import { z } from "zod";
import { MovieMetadataSchema } from "@/models/internals/metadata/movie";
import { SeriesMetadataSchema } from "@/models/internals/metadata/series";
import { MovieMetadataSchema } from "../internals/metadata/movie";
import { SeriesMetadataSchema } from "../internals/metadata/series";
export const TitleDetailsInput = z.object({
titleId: z.uuidv7(),
@@ -37,3 +37,20 @@ export const TitleSeasonsOutput = z.object({
),
});
export type TitleSeasonsOutput = z.infer<typeof TitleSeasonsOutput>;
export const TitleSearchInputQuery = z.object({
q: z.string().max(100),
limit: z.coerce.number().default(20),
});
export type TitleSearchInputQuery = z.infer<typeof TitleSearchInputQuery>;
export const TitleSearchOutput = z.array(
z.object({
id: z.string(),
title: z.string(),
year: z.number().nullable(),
poster: z.string().nullable(),
type: z.string(),
}),
);
export type TitleSearchOutput = z.infer<typeof TitleSearchOutput>;

View File

@@ -0,0 +1,6 @@
import { Hono } from "hono";
import { LibraryRouter } from "./dashboard/library";
import { StatsRouter } from "./dashboard/stats";
export const DashboardRouter = new Hono()
.route("/library ", LibraryRouter)
.route("/stats", StatsRouter);

View File

@@ -0,0 +1,163 @@
import { zValidator } from "@hono/zod-validator";
import { eq } from "drizzle-orm";
import { Hono } from "hono";
import { database } from "@/db";
import { libraryFolders } from "@/db/schema";
import { createHTTPException } from "@/lib/errors";
import {
LibraryCreateInput,
type LibraryCreateOutput,
LibraryDeleteInput,
type LibraryDeleteOutput,
LibraryGetInput,
type LibraryGetOutput,
type LibraryListOutput,
LibraryUpdateInput,
LibraryUpdateInputParam,
type LibraryUpdateOutput,
} from "@/models/api";
export const LibraryRouter = new Hono()
.get("/", async (c) => {
const libraries = await database.select().from(libraryFolders);
return c.json<LibraryListOutput>(libraries);
})
.get("/:id", zValidator("param", LibraryGetInput), async (c) => {
const { id } = c.req.valid("param");
const [library] = await database
.select()
.from(libraryFolders)
.where(eq(libraryFolders.id, id))
.limit(1);
if (!library) {
throw createHTTPException(404, `Library with id ${id} not found.`);
}
return c.json<LibraryGetOutput>(library);
})
.patch(
"/:id",
zValidator("param", LibraryUpdateInputParam),
zValidator("json", LibraryUpdateInput),
async (c) => {
const { id } = c.req.valid("param");
const input = c.req.valid("json");
const [existingLibrary] = await database
.select()
.from(libraryFolders)
.where(eq(libraryFolders.id, id))
.limit(1);
if (!existingLibrary) {
throw createHTTPException(404, `Library with id ${id} not found`);
}
if (input.name) {
const [existingByName] = await database
.select()
.from(libraryFolders)
.where(eq(libraryFolders.name, input.name))
.limit(1);
if (existingByName && existingByName.id !== id) {
throw createHTTPException(
409,
`A library with the name "${input.name}" already exists.`,
);
}
}
if (input.path) {
const [existingByPath] = await database
.select()
.from(libraryFolders)
.where(eq(libraryFolders.path, input.path))
.limit(1);
if (existingByPath && existingByPath.id !== id) {
throw createHTTPException(
409,
`A library with the path "${input.path}" already exists.`,
);
}
}
const updateData: Record<string, unknown> = {};
if (input.name !== undefined) updateData.name = input.name;
if (input.path !== undefined) updateData.path = input.path;
if (input.primaryLanguage !== undefined)
updateData.primaryLanguage = input.primaryLanguage;
if (input.fallbackLanguage !== undefined)
updateData.fallbackLanguage = input.fallbackLanguage;
const [updatedLibrary] = await database
.update(libraryFolders)
.set(updateData)
.where(eq(libraryFolders.id, id))
.returning();
return c.json<LibraryUpdateOutput>(updatedLibrary);
},
)
.delete("/:id", zValidator("param", LibraryDeleteInput), async (c) => {
const { id } = c.req.valid("param");
const [existingLibrary] = await database
.select()
.from(libraryFolders)
.where(eq(libraryFolders.id, id))
.limit(1);
if (!existingLibrary) {
throw createHTTPException(404, `Library with id ${id} not found.`);
}
await database.delete(libraryFolders).where(eq(libraryFolders.id, id));
return c.json<LibraryDeleteOutput>({
success: true,
message: `Library with id ${id} deleted successfully.`,
});
})
.post("/create", zValidator("json", LibraryCreateInput), async (c) => {
const input = c.req.valid("json");
const existingByName = await database
.select()
.from(libraryFolders)
.where(eq(libraryFolders.name, input.name))
.limit(1);
if (existingByName.length > 0) {
throw createHTTPException(
409,
`A library with the name "${input.name}" already exists.`,
);
}
const existingByPath = await database
.select()
.from(libraryFolders)
.where(eq(libraryFolders.path, input.path))
.limit(1);
if (existingByPath.length > 0) {
throw createHTTPException(
409,
`A library with the path "${input.path}" already exists.`,
);
}
const [newLibrary] = await database
.insert(libraryFolders)
.values({
name: input.name,
type: input.type,
path: input.path,
primaryLanguage: input.primaryLanguage,
fallbackLanguage: input.fallbackLanguage,
})
.returning();
return c.json<LibraryCreateOutput>({
success: true,
message: `Library with id ${newLibrary.id} created successfully.`,
data: newLibrary,
});
});

View File

@@ -0,0 +1,24 @@
import { count } from "drizzle-orm";
import { Hono } from "hono";
import { database } from "@/db";
import { episodes, libraryFolders, movies, series, user } from "@/db/schema";
import type { StatsOverviewOutput } from "@/models/api";
export const StatsRouter = new Hono().get("/overview", async (c) => {
const [usersCount, moviesCount, seriesCount, episodesCount, librariesCount] =
await Promise.all([
database.select({ count: count() }).from(user),
database.select({ count: count() }).from(movies),
database.select({ count: count() }).from(series),
database.select({ count: count() }).from(episodes),
database.select({ count: count() }).from(libraryFolders),
]);
return c.json<StatsOverviewOutput>({
totalUsers: usersCount[0].count,
totalMovies: moviesCount[0].count,
totalSeries: seriesCount[0].count,
totalEpisodes: episodesCount[0].count,
totalLibraries: librariesCount[0].count,
});
});

View File

@@ -1,5 +1,5 @@
import { zValidator } from "@hono/zod-validator";
import { and, desc, eq, gte } from "drizzle-orm";
import { and, desc, eq } from "drizzle-orm";
import { Hono } from "hono";
import { database } from "@/db";
import {
@@ -20,11 +20,7 @@ import {
export const MediaRouter = new Hono()
.get("/recentlyAdded", zValidator("query", RecentlyAddedInput), async (c) => {
const { limit } = c.req.valid("query");
// Calculate the date 7 days ago from now
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
// Query titles (movies and series) that were created in the last 7 days
const recentlyAddedTitles = await database
.select({
id: titles.id,
@@ -34,7 +30,6 @@ export const MediaRouter = new Hono()
createdAt: titles.createdAt,
})
.from(titles)
.where(gte(titles.createdAt, sevenDaysAgo))
.orderBy(desc(titles.createdAt))
.limit(limit);

View File

@@ -1,5 +1,5 @@
import { zValidator } from "@hono/zod-validator";
import { and, count, eq, inArray, ne, sql } from "drizzle-orm";
import { and, count, desc, eq, inArray, like, ne, or, sql } from "drizzle-orm";
import { Hono } from "hono";
import { database } from "@/db";
import {
@@ -18,6 +18,8 @@ import { authenticatedMiddleware } from "@/middleware/authenticated";
import {
TitleDetailsInput,
type TitleDetailsOutput,
TitleSearchInputQuery,
type TitleSearchOutput,
TitleSeasonsInput,
type TitleSeasonsOutput,
} from "@/models/api/title";
@@ -286,4 +288,27 @@ export const TitleRouter = new Hono()
})),
});
},
);
)
.get("/search", zValidator("query", TitleSearchInputQuery), async (c) => {
const { q: query, limit } = c.req.valid("query");
const searchPattern = `%${query}%`;
const results = await database
.select({
id: titles.id,
title: titles.title,
year: titles.year,
poster: titles.poster,
type: titles.type,
})
.from(titles)
.where(
or(
like(titles.title, searchPattern),
like(titles.originalTitle, searchPattern),
),
)
.orderBy(desc(titles.popularity), desc(titles.year))
.limit(limit ?? 20);
return c.json<TitleSearchOutput>(results);
});

10
apps/web-client/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
count.txt
.env
.nitro
.tanstack
.wrangler

View File

@@ -0,0 +1,3 @@
package-lock.json
pnpm-lock.yaml
yarn.lock

310
apps/web-client/README.md Normal file
View File

@@ -0,0 +1,310 @@
Welcome to your new TanStack app!
# Getting Started
To run this application:
```bash
bun install
bun --bun run start
```
# Building For Production
To build this application for production:
```bash
bun --bun run build
```
## Testing
This project uses [Vitest](https://vitest.dev/) for testing. You can run the tests with:
```bash
bun --bun run test
```
## Styling
This project uses [Tailwind CSS](https://tailwindcss.com/) for styling.
## Linting & Formatting
This project uses [eslint](https://eslint.org/) and [prettier](https://prettier.io/) for linting and formatting. Eslint is configured using [tanstack/eslint-config](https://tanstack.com/config/latest/docs/eslint). The following scripts are available:
```bash
bun --bun run lint
bun --bun run format
bun --bun run check
```
## Shadcn
Add components using the latest version of [Shadcn](https://ui.shadcn.com/).
```bash
pnpx shadcn@latest add button
```
## Routing
This project uses [TanStack Router](https://tanstack.com/router). The initial setup is a file based router. Which means that the routes are managed as files in `src/routes`.
### Adding A Route
To add a new route to your application just add another a new file in the `./src/routes` directory.
TanStack will automatically generate the content of the route file for you.
Now that you have two routes you can use a `Link` component to navigate between them.
### Adding Links
To use SPA (Single Page Application) navigation you will need to import the `Link` component from `@tanstack/react-router`.
```tsx
import { Link } from "@tanstack/react-router";
```
Then anywhere in your JSX you can use it like so:
```tsx
<Link to="/about">About</Link>
```
This will create a link that will navigate to the `/about` route.
More information on the `Link` component can be found in the [Link documentation](https://tanstack.com/router/v1/docs/framework/react/api/router/linkComponent).
### Using A Layout
In the File Based Routing setup the layout is located in `src/routes/__root.tsx`. Anything you add to the root route will appear in all the routes. The route content will appear in the JSX where you use the `<Outlet />` component.
Here is an example layout that includes a header:
```tsx
import { Outlet, createRootRoute } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
import { Link } from "@tanstack/react-router";
export const Route = createRootRoute({
component: () => (
<>
<header>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
</nav>
</header>
<Outlet />
<TanStackRouterDevtools />
</>
),
})
```
The `<TanStackRouterDevtools />` component is not required so you can remove it if you don't want it in your layout.
More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts).
## Data Fetching
There are multiple ways to fetch data in your application. You can use TanStack Query to fetch data from a server. But you can also use the `loader` functionality built into TanStack Router to load the data for a route before it's rendered.
For example:
```tsx
const peopleRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/people",
loader: async () => {
const response = await fetch("https://swapi.dev/api/people");
return response.json() as Promise<{
results: {
name: string;
}[];
}>;
},
component: () => {
const data = peopleRoute.useLoaderData();
return (
<ul>
{data.results.map((person) => (
<li key={person.name}>{person.name}</li>
))}
</ul>
);
},
});
```
Loaders simplify your data fetching logic dramatically. Check out more information in the [Loader documentation](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#loader-parameters).
### React-Query
React-Query is an excellent addition or alternative to route loading and integrating it into you application is a breeze.
First add your dependencies:
```bash
bun install @tanstack/react-query @tanstack/react-query-devtools
```
Next we'll need to create a query client and provider. We recommend putting those in `main.tsx`.
```tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// ...
const queryClient = new QueryClient();
// ...
if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
}
```
You can also add TanStack Query Devtools to the root route (optional).
```tsx
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
const rootRoute = createRootRoute({
component: () => (
<>
<Outlet />
<ReactQueryDevtools buttonPosition="top-right" />
<TanStackRouterDevtools />
</>
),
});
```
Now you can use `useQuery` to fetch your data.
```tsx
import { useQuery } from "@tanstack/react-query";
import "./App.css";
function App() {
const { data } = useQuery({
queryKey: ["people"],
queryFn: () =>
fetch("https://swapi.dev/api/people")
.then((res) => res.json())
.then((data) => data.results as { name: string }[]),
initialData: [],
});
return (
<div>
<ul>
{data.map((person) => (
<li key={person.name}>{person.name}</li>
))}
</ul>
</div>
);
}
export default App;
```
You can find out everything you need to know on how to use React-Query in the [React-Query documentation](https://tanstack.com/query/latest/docs/framework/react/overview).
## State Management
Another common requirement for React applications is state management. There are many options for state management in React. TanStack Store provides a great starting point for your project.
First you need to add TanStack Store as a dependency:
```bash
bun install @tanstack/store
```
Now let's create a simple counter in the `src/App.tsx` file as a demonstration.
```tsx
import { useStore } from "@tanstack/react-store";
import { Store } from "@tanstack/store";
import "./App.css";
const countStore = new Store(0);
function App() {
const count = useStore(countStore);
return (
<div>
<button onClick={() => countStore.setState((n) => n + 1)}>
Increment - {count}
</button>
</div>
);
}
export default App;
```
One of the many nice features of TanStack Store is the ability to derive state from other state. That derived state will update when the base state updates.
Let's check this out by doubling the count using derived state.
```tsx
import { useStore } from "@tanstack/react-store";
import { Store, Derived } from "@tanstack/store";
import "./App.css";
const countStore = new Store(0);
const doubledStore = new Derived({
fn: () => countStore.state * 2,
deps: [countStore],
});
doubledStore.mount();
function App() {
const count = useStore(countStore);
const doubledCount = useStore(doubledStore);
return (
<div>
<button onClick={() => countStore.setState((n) => n + 1)}>
Increment - {count}
</button>
<div>Doubled - {doubledCount}</div>
</div>
);
}
export default App;
```
We use the `Derived` class to create a new store that is derived from another store. The `Derived` class has a `mount` method that will start the derived store updating.
Once we've created the derived store we can use it in the `App` component just like we would any other store using the `useStore` hook.
You can find out everything you need to know on how to use TanStack Store in the [TanStack Store documentation](https://tanstack.com/store/latest).
# Demo files
Files prefixed with `demo` can be safely deleted. They are there to provide a starting point for you to play around with the features you've installed.
# Learn More
You can learn more about all of the offerings from TanStack in the [TanStack documentation](https://tanstack.com).

View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -0,0 +1,88 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<!-- Preload the logo so it shows up quickly -->
<link rel="preload" as="image" href="/src/assets/logo.svg" type="image/svg+xml" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-tsrouter-app"
/>
<link rel="manifest" href="/manifest.json" />
<title>Nontara</title>
<!-- Critical preloader CSS: parsed before any JS -->
<style>
/* Minimal palette only for preloader (no app vars dependency) */
:root{
--preloader-bg: oklch(1 0 0);
--preloader-fg: oklch(0.145 0 0);
--preloader-muted: color-mix(in oklab, black 15%, white);
--preloader-primary: oklch(0.65 0.12 280);
}
@media (prefers-color-scheme: dark){
:root{
--preloader-bg: oklch(0.145 0 0);
--preloader-fg: oklch(0.985 0 0);
--preloader-muted: color-mix(in oklab, white 20%, black);
--preloader-primary: oklch(0.75 0.12 280);
}
}
/* Spinner animation defined up-front so it can run immediately */
@keyframes spin { to { transform: rotate(360deg); } }
/* Preloader layout */
#preloader{
position: fixed; inset: 0;
display: flex; flex-direction: column;
justify-content: center; align-items: center; gap: 20px;
background-color: var(--preloader-bg);
z-index: 9999;
transition: opacity .5s ease-out, visibility .5s ease-out, transform .5s ease-out, filter .5s ease-out;
will-change: opacity, visibility, transform, filter;
transform: translateY(0);
filter: blur(0);
}
#preloader.hidden{
opacity: 0;
visibility: hidden;
transform: translateY(12px) scale(.97);
filter: blur(2px);
}
#preloader .logo{
max-width: 300px; max-height: 300px; width: auto; height: auto;
}
.spinner{
width: 50px; height: 50px;
border: 4px solid var(--preloader-muted);
border-top-color: var(--preloader-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
/* Respect reduced motion */
@media (prefers-reduced-motion: reduce){
.spinner{ animation: none; }
}
/* Optional: prevent layout shift on body while app hydrates */
body{ margin: 0; }
</style>
</head>
<body>
<!-- Preloader is first in body so it paints ASAP -->
<div id="preloader" aria-live="polite" aria-busy="true">
<img class="logo" src="/src/assets/logo.svg" alt="Nontara Logo" />
<div class="spinner" role="status" aria-label="Loading"></div>
</div>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,68 @@
{
"name": "web-client",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 3001",
"build": "vite build && tsc",
"serve": "vite preview",
"test": "vitest run",
"lint": "eslint",
"format": "prettier",
"check": "prettier --write . && eslint --fix",
"shadcn": "bunx --bun shadcn@latest"
},
"dependencies": {
"@nontara/language-codes": "workspace:*",
"@nontara/server": "workspace:*",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.0.6",
"@tanstack/react-devtools": "^0.7.0",
"@tanstack/react-query": "^5.66.5",
"@tanstack/react-query-devtools": "^5.84.2",
"@tanstack/react-router": "^1.132.0",
"@tanstack/react-router-devtools": "^1.132.0",
"@tanstack/router-plugin": "^1.132.0",
"axios": "^1.13.2",
"better-auth": "^1.3.34",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"eslint": "^9.39.1",
"lodash": "^4.17.21",
"lucide-react": "^0.552.0",
"media-chrome": "^4.15.1",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-player": "^3.3.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.0.6",
"tw-animate-css": "^1.3.6",
"zod": "^4.1.12"
},
"devDependencies": {
"@tanstack/eslint-config": "^0.3.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0",
"@types/node": "^22.10.2",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.4",
"babel-plugin-react-compiler": "^1.0.0",
"jsdom": "^27.0.0",
"prettier": "^3.5.3",
"typescript": "^5.7.2",
"vite": "^7.1.7",
"vitest": "^3.0.5",
"web-vitals": "^5.1.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -0,0 +1 @@
This is a placeholder for the logo.png file.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -0,0 +1,15 @@
{
"short_name": "Nontara",
"name": "Nontara Media Server",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="1024" height="1536">
<path d="M0 0 C337.92 0 675.84 0 1024 0 C1024 506.88 1024 1013.76 1024 1536 C686.08 1536 348.16 1536 0 1536 C0 1029.12 0 522.24 0 0 Z " fill="#267FC5" transform="translate(0,0)"/>
<path d="M0 0 C0.9761566 -0.00467997 1.95231321 -0.00935994 2.95805031 -0.01418173 C6.22608344 -0.02613917 9.49369782 -0.01667133 12.76173401 -0.00723267 C15.10591374 -0.01129805 17.45009156 -0.01663974 19.79426575 -0.02316284 C26.15992765 -0.03684686 32.5254438 -0.03153301 38.89110827 -0.02202559 C44.87652793 -0.01520325 50.86193419 -0.02040927 56.84735347 -0.02480717 C69.42567052 -0.03335888 82.00392196 -0.02353073 94.58222825 -0.00740607 C105.36882164 0.00599382 116.15531815 0.00367134 126.94190979 -0.01016235 C139.47420234 -0.02622741 152.00643738 -0.03253166 164.53873879 -0.02332556 C171.17040088 -0.01858612 177.802009 -0.01802539 184.43366814 -0.02793503 C190.66927099 -0.03660177 196.90469095 -0.03058785 203.14027405 -0.01325226 C205.42877969 -0.00928572 207.71730073 -0.0104243 210.00580025 -0.01704025 C213.12988838 -0.02523906 216.25339059 -0.01495369 219.37744141 0 C220.73562246 -0.01003825 220.73562246 -0.01003825 222.12124151 -0.02027929 C229.41815915 0.04745644 235.2821078 1.69169457 240.84494781 6.78812599 C248.5196927 15.47485347 247.95540497 25.66540396 247.8674469 36.55526733 C247.87095252 38.38610854 247.87661605 40.21694668 247.88426208 42.04777527 C247.89845432 46.99611386 247.88150438 51.94389305 247.85797691 56.89217806 C247.83780256 62.08141606 247.84375079 67.27063232 247.84608459 72.4598999 C247.8460311 81.17033056 247.82632021 89.88054371 247.79420471 98.59091187 C247.75729237 108.65097593 247.74853324 118.71075548 247.75550222 128.77087843 C247.76181373 138.46505221 247.75104241 148.15912878 247.73153687 157.85328293 C247.72337767 161.97191125 247.72065058 166.0904898 247.722229 170.20912552 C247.72310986 175.06018152 247.7088377 179.91096839 247.68300247 184.76195335 C247.67585926 186.5388515 247.67374813 188.31577831 247.67712021 190.09268761 C247.68085379 192.52440173 247.66602333 194.95533849 247.6466217 197.38696289 C247.65194963 198.08444629 247.65727756 198.7819297 247.66276693 199.50054896 C247.56909997 206.33846145 245.55224672 212.88063575 240.8761009 218.00913721 C234.35707924 223.51621729 227.34082722 225.11899978 219.03013611 225.00914001 C218.05126737 225.0162344 217.07239864 225.02332879 216.06386721 225.03063816 C212.8103218 225.04812664 209.55798554 225.03015959 206.30445862 225.01205444 C203.96130838 225.01719666 201.61816201 225.02448021 199.27502441 225.03373718 C192.93009414 225.05221844 186.58558521 225.03933364 180.24067259 225.01964688 C174.26831243 225.00490103 168.29598242 225.01087345 162.32361396 225.01570379 C150.49082541 225.02425764 138.65820286 225.00763729 126.82545471 224.97787476 C115.35630831 224.94902738 103.8874009 224.94196167 92.41822815 224.95736694 C79.91114647 224.97414345 67.40417807 224.97814959 54.89709342 224.96065658 C48.28277738 224.95151613 41.66854634 224.94744459 35.05422592 224.95669746 C28.83737819 224.96436372 22.62087428 224.95222242 16.40408516 224.92484856 C14.12406624 224.91796792 11.8440152 224.91786328 9.56399727 224.92506409 C6.44905627 224.93376756 3.33517424 224.91778662 0.22032166 224.89561462 C-0.67949106 224.90385539 -1.57930377 224.91209616 -2.50638354 224.92058665 C-9.29026987 224.83160779 -14.50782043 223.11168936 -19.73606873 218.70516968 C-20.17306091 218.16505249 -20.6100531 217.6249353 -21.06028748 217.06845093 C-21.73511169 216.26987671 -21.73511169 216.26987671 -22.42356873 215.45516968 C-26.00920544 209.83872987 -27.19153319 205.03690983 -27.13546753 198.41783142 C-27.14085307 197.64570358 -27.1462386 196.87357573 -27.15178734 196.07805008 C-27.16602573 193.50640737 -27.15897355 190.9352824 -27.15184021 188.36361694 C-27.15754593 186.5135151 -27.16451097 184.66341677 -27.17263794 182.81332397 C-27.1905619 177.79884757 -27.18940241 172.78454623 -27.18369555 167.77004743 C-27.18060268 163.58017404 -27.18669725 159.39033423 -27.1927343 155.20046562 C-27.20677535 145.31455399 -27.20522165 135.42872879 -27.1938324 125.54281616 C-27.18235068 115.35157403 -27.19642877 105.16059505 -27.22322887 94.96938759 C-27.24539519 86.21163792 -27.25202116 77.45397079 -27.24615246 68.69619507 C-27.24278404 63.46902059 -27.24519048 58.2420148 -27.26240349 53.01486397 C-27.2778854 48.09898489 -27.27390315 43.18349529 -27.25509834 38.26763153 C-27.25141597 36.46653505 -27.25451067 34.66540963 -27.26498413 32.86433983 C-27.27822023 30.39946496 -27.26662444 27.93587456 -27.24899292 25.47105408 C-27.25859793 24.76374242 -27.26820293 24.05643076 -27.278099 23.32768542 C-27.18741071 17.13684235 -25.0450013 12.05054416 -21.03684998 7.39266968 C-20.48770935 6.95567749 -19.93856873 6.5186853 -19.37278748 6.06845093 C-18.8288031 5.61856812 -18.28481873 5.1686853 -17.72434998 4.70516968 C-11.65920983 0.64176551 -7.12322852 -0.05251804 0 0 Z " fill="#E8E9E9" transform="translate(401.74778747558594,665.6190490722656)"/>
<path d="M0 0 C5.46026979 0.99124898 9.20135539 3.6678218 13.74609375 6.75390625 C15.71795217 8.05069314 17.69063696 9.34622417 19.6640625 10.640625 C21.18652588 11.64875244 21.18652588 11.64875244 22.73974609 12.67724609 C27.6297506 15.88667626 32.59234651 18.97678081 37.55859375 22.06640625 C38.53747559 22.67798584 39.51635742 23.28956543 40.52490234 23.91967773 C45.95587106 27.30908434 51.40094613 30.67460693 56.859375 34.01953125 C57.44663361 34.37967316 58.03389221 34.73981506 58.63894653 35.11087036 C61.48006471 36.85282791 64.32294313 38.59170388 67.16894531 40.32568359 C70.06102106 42.09568477 72.94000626 43.8836277 75.80859375 45.69140625 C76.92681519 46.39209229 76.92681519 46.39209229 78.06762695 47.10693359 C80.62718335 48.8020256 82.52219 50.07969107 83.84375 52.90234375 C83.88891839 56.47709944 83.67915016 58.75162487 81.1875 61.44140625 C78.48800642 63.38766159 75.69473568 65.03297687 72.80859375 66.69140625 C71.54171641 67.45934332 70.27754463 68.23175556 69.015625 69.0078125 C66.83370103 70.34905626 64.65018349 71.68697255 62.45922852 73.01342773 C55.69417693 77.11223294 49.02634515 81.3545761 42.37109375 85.62890625 C34.67787041 90.56987309 26.97911339 95.50039331 19.24609375 100.37890625 C18.50367432 100.8473999 17.76125488 101.31589355 16.99633789 101.79858398 C15.61005038 102.67238543 14.2221865 103.54369258 12.83251953 104.41210938 C11.42520417 105.30165491 10.02837567 106.20792081 8.64013672 107.12695312 C7.93904785 107.58521484 7.23795898 108.04347656 6.515625 108.515625 C5.58133667 109.13763794 5.58133667 109.13763794 4.62817383 109.7722168 C2.80859375 110.69140625 2.80859375 110.69140625 -0.94140625 110.81640625 C-4.98319232 109.41732646 -5.98418586 108.34338115 -8.19140625 104.69140625 C-8.77594083 101.49831471 -8.74834382 98.35444926 -8.72485352 95.11523438 C-8.73346909 94.15693222 -8.74208466 93.19863007 -8.7509613 92.21128845 C-8.7736376 89.0481352 -8.76687956 85.88586662 -8.7578125 82.72265625 C-8.76304707 80.52217343 -8.76932294 78.32169285 -8.77659607 76.12121582 C-8.78700318 71.51120438 -8.78243375 66.90150156 -8.76782227 62.29150391 C-8.75062826 56.38297374 -8.77413645 50.47541544 -8.80931187 44.56699371 C-8.83126771 40.0236364 -8.82984882 35.48051231 -8.82154655 30.93711853 C-8.82072616 28.75861037 -8.8276063 26.58008523 -8.84231758 24.40162659 C-8.85973079 21.35633503 -8.84524735 18.31279892 -8.82250977 15.26757812 C-8.83420197 14.36768661 -8.84589417 13.4677951 -8.85794067 12.54063416 C-8.79063227 8.11164457 -8.62389989 6.25611034 -5.85142517 2.6361084 C-3.19140625 0.69140625 -3.19140625 0.69140625 0 0 Z " fill="#267EC4" transform="translate(474.19140625,716.30859375)"/>
<path d="M0 0 C0.90234375 -0.01998047 1.8046875 -0.03996094 2.734375 -0.06054688 C6.52988289 -0.05773955 9.22743587 0.02221778 12.6015625 1.8359375 C14.81294304 5.94984655 14.49897414 9.90969449 14.375 14.5 C14.37371094 15.41072266 14.37242187 16.32144531 14.37109375 17.25976562 C14.28148496 24.01527256 14.28148496 24.01527256 12 27.4375 C9.6015625 27.82568359 9.6015625 27.82568359 6.625 27.8359375 C5.02398438 27.84173828 5.02398438 27.84173828 3.390625 27.84765625 C2.27171875 27.83605469 1.1528125 27.82445312 0 27.8125 C-1.11890625 27.82410156 -2.2378125 27.83570313 -3.390625 27.84765625 C-4.45796875 27.84378906 -5.5253125 27.83992187 -6.625 27.8359375 C-7.60726562 27.83255371 -8.58953125 27.82916992 -9.6015625 27.82568359 C-10.39304687 27.69758301 -11.18453125 27.56948242 -12 27.4375 C-14.91351961 23.06722058 -14.36811757 19.07867839 -14.375 13.9375 C-14.39949219 12.97457031 -14.42398438 12.01164063 -14.44921875 11.01953125 C-14.45308594 10.09527344 -14.45695313 9.17101562 -14.4609375 8.21875 C-14.46915527 7.37376953 -14.47737305 6.52878906 -14.48583984 5.65820312 C-14 3.4375 -14 3.4375 -12.26025391 1.66577148 C-8.33674695 -0.46634814 -4.37449317 -0.03437102 0 0 Z " fill="#2481CA" transform="translate(618,794.5625)"/>
<path d="M0 0 C1.25889351 -0.00782324 1.25889351 -0.00782324 2.54321921 -0.01580453 C3.46014025 -0.01134309 4.37706128 -0.00688164 5.32176781 -0.002285 C6.78873118 -0.00696498 6.78873118 -0.00696498 8.28533018 -0.01173949 C11.57116765 -0.01973398 14.8568212 -0.01339017 18.14266014 -0.00710678 C20.49465608 -0.00981596 22.84665117 -0.01337648 25.19864464 -0.0177269 C31.59298731 -0.02686084 37.98726561 -0.02330274 44.38160944 -0.01696873 C50.38642446 -0.01162531 56.39123833 -0.0158888 62.39605372 -0.01882312 C75.0113939 -0.02452721 87.62670502 -0.01796681 100.24204044 -0.00722238 C111.07399983 0.00171734 121.90591637 0.00015778 132.73787498 -0.00905991 C145.31074777 -0.01975401 157.8835951 -0.02398224 170.4564718 -0.01783538 C177.11426375 -0.01466898 183.77202996 -0.0143199 190.42981911 -0.02090836 C196.68586175 -0.02666661 202.94182309 -0.02271291 209.1978569 -0.01111984 C211.49975067 -0.00846532 213.80165123 -0.00925175 216.10354233 -0.01364517 C219.23375099 -0.01907077 222.36369535 -0.01230304 225.49388695 -0.002285 C226.41526943 -0.00674645 227.33665191 -0.01120789 228.28595507 -0.01580453 C234.5525434 0.02338195 234.5525434 0.02338195 236.78084373 2.25168228 C158.57084373 1.92168228 80.36084373 1.59168228 -0.21915627 1.25168228 C-0.21915627 1.91168228 -0.21915627 2.57168228 -0.21915627 3.25168228 C77.33084373 3.25168228 154.88084373 3.25168228 234.78084373 3.25168228 C235.11084373 3.91168228 235.44084373 4.57168228 235.78084373 5.25168228 C156.91084373 5.25168228 78.04084373 5.25168228 -3.21915627 5.25168228 C-3.54915627 4.59168228 -3.87915627 3.93168228 -4.21915627 3.25168228 C-6.24378882 2.59954936 -6.24378882 2.59954936 -8.21915627 2.25168228 C-5.34714145 0.62360351 -3.29399238 0.00147849 0 0 Z " fill="#E7F1F5" transform="translate(396.2191562652588,665.7483177185059)"/>
<path d="M0 0 C0.90234375 -0.01998047 1.8046875 -0.03996094 2.734375 -0.06054688 C6.52849028 -0.05774058 9.22582938 0.02072845 12.59765625 1.8359375 C14.81483905 5.94915127 14.53872611 9.90564088 14.4375 14.5 C14.44458984 15.41072266 14.45167969 16.32144531 14.45898438 17.25976562 C14.39578977 23.85630972 14.39578977 23.85630972 12.80126953 26.19775391 C10.3586685 27.87890436 8.69724865 27.81018874 5.74609375 27.80078125 C4.72708984 27.80013672 3.70808594 27.79949219 2.65820312 27.79882812 C1.59537109 27.78271484 0.53253906 27.76660156 -0.5625 27.75 C-2.16254883 27.75483398 -2.16254883 27.75483398 -3.79492188 27.75976562 C-11.73303472 27.70446528 -11.73303472 27.70446528 -14 25.4375 C-14.47898442 21.81211122 -14.3703783 18.1534522 -14.375 14.5 C-14.39949219 13.48486328 -14.42398437 12.46972656 -14.44921875 11.42382812 C-14.45308594 10.44994141 -14.45695312 9.47605469 -14.4609375 8.47265625 C-14.46915527 7.5794165 -14.47737305 6.68617676 -14.48583984 5.76586914 C-14 3.4375 -14 3.4375 -12.26025391 1.64990234 C-8.33057351 -0.45798196 -4.37029864 -0.03433806 0 0 Z " fill="#2581C7" transform="translate(406,794.5625)"/>
<path d="M0 0 C0.95455078 -0.01998047 1.90910156 -0.03996094 2.89257812 -0.06054688 C6.83369619 -0.05778892 9.70043075 -0.03190566 13.1953125 1.87109375 C15.30499918 5.83127703 15.10323596 9.52166488 15 13.9375 C15.00708984 14.79214844 15.01417969 15.64679687 15.02148438 16.52734375 C14.94457443 24.11184743 14.94457443 24.11184743 11.5625 27.4375 C9.25 27.93920898 9.25 27.93920898 6.5625 27.93359375 C5.1084375 27.93456055 5.1084375 27.93456055 3.625 27.93554688 C2.614375 27.91556641 1.60375 27.89558594 0.5625 27.875 C-0.448125 27.88208984 -1.45875 27.88917969 -2.5 27.89648438 C-9.86211823 27.83284904 -9.86211823 27.83284904 -12.22509766 26.20922852 C-13.80195523 23.90490805 -13.82480154 22.43705829 -13.8359375 19.65625 C-13.83980469 18.73199219 -13.84367187 17.80773437 -13.84765625 16.85546875 C-13.83605469 15.89253906 -13.82445312 14.92960938 -13.8125 13.9375 C-13.82410156 12.97457031 -13.83570313 12.01164063 -13.84765625 11.01953125 C-13.84378906 10.09527344 -13.83992188 9.17101562 -13.8359375 8.21875 C-13.83255371 7.37376953 -13.82916992 6.52878906 -13.82568359 5.65820312 C-13.4375 3.4375 -13.4375 3.4375 -12.20922852 1.66577148 C-8.66285805 -0.79279126 -4.16636003 -0.03082042 0 0 Z " fill="#277FC4" transform="translate(405.4375,686.5625)"/>
<path d="M0 0 C0.91974609 -0.00386719 1.83949219 -0.00773438 2.78710938 -0.01171875 C3.74552734 -0.00011719 4.70394531 0.01148438 5.69140625 0.0234375 C7.12323242 0.00603516 7.12323242 0.00603516 8.58398438 -0.01171875 C9.50501953 -0.00785156 10.42605469 -0.00398437 11.375 0 C12.63461548 0.00507568 12.63461548 0.00507568 13.91967773 0.01025391 C16.83226154 0.49461833 18.18860892 1.29487228 20.25390625 3.3984375 C20.75561523 5.51147461 20.75561523 5.51147461 20.75 7.92578125 C20.75064453 8.80041016 20.75128906 9.67503906 20.75195312 10.57617188 C20.73197266 11.48689453 20.71199219 12.39761719 20.69140625 13.3359375 C20.69849609 14.24279297 20.70558594 15.14964844 20.71289062 16.08398438 C20.64997204 22.66139689 20.64997204 22.66139689 19.03930664 25.65869141 C16.70250234 27.93574151 15.48050769 27.90127288 12.25390625 27.89453125 C10.79984375 27.89549805 10.79984375 27.89549805 9.31640625 27.89648438 C8.30578125 27.87650391 7.29515625 27.85652344 6.25390625 27.8359375 C5.24328125 27.84302734 4.23265625 27.85011719 3.19140625 27.85742188 C-4.17071198 27.79378654 -4.17071198 27.79378654 -6.53369141 26.17016602 C-8.11054898 23.86584555 -8.13339529 22.39799579 -8.14453125 19.6171875 C-8.14839844 18.69292969 -8.15226563 17.76867187 -8.15625 16.81640625 C-8.14464844 15.85347656 -8.13304687 14.89054688 -8.12109375 13.8984375 C-8.13269531 12.93550781 -8.14429688 11.97257813 -8.15625 10.98046875 C-8.15238281 10.05621094 -8.14851562 9.13195312 -8.14453125 8.1796875 C-8.14114746 7.33470703 -8.13776367 6.48972656 -8.13427734 5.61914062 C-7.74609375 3.3984375 -7.74609375 3.3984375 -6.51782227 1.62451172 C-4.2219837 0.03574208 -2.77999919 0.01121721 0 0 Z " fill="#237DC3" transform="translate(611.74609375,686.6015625)"/>
<path d="M0 0 C0.90234375 -0.01998047 1.8046875 -0.03996094 2.734375 -0.06054688 C6.52849028 -0.05774058 9.22582938 0.02072845 12.59765625 1.8359375 C14.81483905 5.94915127 14.53872611 9.90564088 14.4375 14.5 C14.44458984 15.41072266 14.45167969 16.32144531 14.45898438 17.25976562 C14.39570905 23.86473601 14.39570905 23.86473601 12.78540039 26.19555664 C10.41915495 27.84154195 8.86968541 27.82498848 6 27.8359375 C5.030625 27.83980469 4.06125 27.84367188 3.0625 27.84765625 C2.051875 27.83605469 1.04125 27.82445312 0 27.8125 C-1.010625 27.82410156 -2.02125 27.83570313 -3.0625 27.84765625 C-4.031875 27.84378906 -5.00125 27.83992187 -6 27.8359375 C-6.886875 27.83255371 -7.77375 27.82916992 -8.6875 27.82568359 C-11 27.4375 -11 27.4375 -12.78540039 26.21142578 C-15.22985491 22.64129381 -14.46827201 18.1168986 -14.4375 13.9375 C-14.45748047 12.97457031 -14.47746094 12.01164063 -14.49804688 11.01953125 C-14.49740234 10.09527344 -14.49675781 9.17101562 -14.49609375 8.21875 C-14.49887329 6.9512793 -14.49887329 6.9512793 -14.50170898 5.65820312 C-14 3.4375 -14 3.4375 -12.25805664 1.66577148 C-8.33634936 -0.46744393 -4.37325984 -0.03436133 0 0 Z " fill="#2480C7" transform="translate(406,740.5625)"/>
<path d="M0 0 C0.969375 -0.00386719 1.93875 -0.00773438 2.9375 -0.01171875 C3.948125 -0.00011719 4.95875 0.01148438 6 0.0234375 C7.010625 0.01183594 8.02125 0.00023437 9.0625 -0.01171875 C10.031875 -0.00785156 11.00125 -0.00398437 12 0 C12.886875 0.00338379 13.77375 0.00676758 14.6875 0.01025391 C17 0.3984375 17 0.3984375 18.78540039 1.64038086 C21.16295556 5.08174262 20.46896853 9.29366117 20.4375 13.3359375 C20.4674707 14.6962207 20.4674707 14.6962207 20.49804688 16.08398438 C20.49524373 19.89345933 20.42432141 22.61243823 18.59765625 25.99609375 C14.50140333 28.20745435 10.57942456 27.97694192 6 27.8984375 C4.64648438 27.92164063 4.64648438 27.92164063 3.265625 27.9453125 C-0.5255723 27.92287938 -3.23185642 27.81311952 -6.59765625 25.99609375 C-8.81662054 21.88575599 -8.53868345 17.92836047 -8.4375 13.3359375 C-8.44458984 12.42521484 -8.45167969 11.51449219 -8.45898438 10.57617188 C-8.39570905 3.97120149 -8.39570905 3.97120149 -6.78540039 1.64038086 C-4.41915495 -0.00560445 -2.86968541 0.01094902 0 0 Z " fill="#257DC3" transform="translate(612,844.6015625)"/>
<path d="M0 0 C1.53333984 -0.00386719 1.53333984 -0.00386719 3.09765625 -0.0078125 C4.16886719 -0.00007812 5.24007813 0.00765625 6.34375 0.015625 C7.95056641 0.00402344 7.95056641 0.00402344 9.58984375 -0.0078125 C10.61207031 -0.00523437 11.63429688 -0.00265625 12.6875 0 C13.62787109 0.00225586 14.56824219 0.00451172 15.53710938 0.00683594 C17.84375 0.265625 17.84375 0.265625 19.84375 2.265625 C20.29670333 5.89207004 20.16779802 9.55164238 20.15625 13.203125 C20.17236328 14.21826172 20.18847656 15.23339844 20.20507812 16.27929688 C20.20572266 17.25318359 20.20636719 18.22707031 20.20703125 19.23046875 C20.21001221 20.1237085 20.21299316 21.01694824 20.21606445 21.93725586 C19.84375 24.265625 19.84375 24.265625 18.61987305 26.05541992 C16.31260058 27.62753671 14.8433091 27.65292654 12.0625 27.6640625 C11.13824219 27.66792969 10.21398437 27.67179688 9.26171875 27.67578125 C8.29878906 27.66417969 7.33585937 27.65257812 6.34375 27.640625 C4.89935547 27.65802734 4.89935547 27.65802734 3.42578125 27.67578125 C2.50152344 27.67191406 1.57726562 27.66804687 0.625 27.6640625 C-0.21998047 27.66067871 -1.06496094 27.65729492 -1.93554688 27.65380859 C-4.15625 27.265625 -4.15625 27.265625 -5.93237305 26.05541992 C-7.56188915 23.67241921 -7.52912311 22.10465549 -7.51953125 19.23046875 C-7.51888672 18.25658203 -7.51824219 17.28269531 -7.51757812 16.27929688 C-7.50146484 15.26416016 -7.48535156 14.24902344 -7.46875 13.203125 C-7.47197266 12.18412109 -7.47519531 11.16511719 -7.47851562 10.11523438 C-7.40490915 0.01776366 -7.40490915 0.01776366 0 0 Z " fill="#257DC3" transform="translate(399.15625,844.734375)"/>
<path d="M0 0 C1.11890625 0.00322266 2.2378125 0.00644531 3.390625 0.00976562 C5.06898438 -0.0144043 5.06898438 -0.0144043 6.78125 -0.0390625 C8.38226562 -0.0400293 8.38226562 -0.0400293 10.015625 -0.04101562 C10.99789062 -0.04399658 11.98015625 -0.04697754 12.9921875 -0.05004883 C14.17941406 0.13424683 14.17941406 0.13424683 15.390625 0.32226562 C18.23862317 4.59426287 17.72080816 8.30024848 17.703125 13.32226562 C17.71923828 14.2375 17.73535156 15.15273438 17.75195312 16.09570312 C17.75259766 16.97484375 17.75324219 17.85398437 17.75390625 18.75976562 C17.75688721 19.56285156 17.75986816 20.3659375 17.76293945 21.19335938 C17.390625 23.32226562 17.390625 23.32226562 16.18041992 25.08251953 C13.81108002 26.72370191 12.26038562 26.70975382 9.390625 26.72070312 C8.42125 26.72457031 7.451875 26.7284375 6.453125 26.73242188 C5.4425 26.72082031 4.431875 26.70921875 3.390625 26.69726562 C2.38 26.70886719 1.369375 26.72046875 0.328125 26.73242188 C-0.64125 26.72855469 -1.610625 26.7246875 -2.609375 26.72070312 C-3.49625 26.71731934 -4.383125 26.71393555 -5.296875 26.71044922 C-7.609375 26.32226562 -7.609375 26.32226562 -9.39916992 25.09838867 C-10.98061678 22.7774232 -10.98244823 21.30239563 -10.97265625 18.50585938 C-10.97201172 17.57708984 -10.97136719 16.64832031 -10.97070312 15.69140625 C-10.95458984 14.72396484 -10.93847656 13.75652344 -10.921875 12.75976562 C-10.92509766 11.78845703 -10.92832031 10.81714844 -10.93164062 9.81640625 C-10.86178929 0.67858178 -8.94821783 0.05943865 0 0 Z " fill="#237DC4" transform="translate(614.609375,741.677734375)"/>
<path d="M0 0 C29.6910449 -0.06844983 59.38208713 -0.12153525 89.07319394 -0.15319599 C92.57915091 -0.15694807 96.08510772 -0.16083104 99.59106445 -0.16479492 C100.63801598 -0.16597532 100.63801598 -0.16597532 101.70611804 -0.16717956 C113.00490166 -0.18023057 124.30363946 -0.20388273 135.6023959 -0.23145839 C147.19748506 -0.25952108 158.7925475 -0.27595356 170.38766903 -0.28226548 C176.89983964 -0.28617888 183.41190633 -0.2953864 189.92404366 -0.31719017 C196.05283732 -0.33754429 202.1814842 -0.3438359 208.31030846 -0.33932304 C210.56011884 -0.34014872 212.80993411 -0.34612064 215.05971527 -0.35761642 C218.13154843 -0.37254343 221.20282746 -0.36895255 224.27467346 -0.36076355 C225.60981148 -0.37387134 225.60981148 -0.37387134 226.97192198 -0.38724393 C232.40097026 -0.34812877 236.15333153 0.52409832 241 3 C237.31343636 5.34045567 234.14571906 5.24188179 229.84472656 5.22705078 C228.77583443 5.22748886 228.77583443 5.22748886 227.68534851 5.22793579 C225.33698219 5.22654577 222.98893569 5.21100022 220.640625 5.1953125 C219.00966566 5.1915812 217.37870408 5.18873478 215.7477417 5.18673706 C211.46020289 5.1791098 207.1728001 5.15946912 202.88531494 5.1373291 C198.50819743 5.11685112 194.13105788 5.10773117 189.75390625 5.09765625 C181.16920849 5.07622235 172.58462118 5.04209739 164 5 C164 4.67 164 4.34 164 4 C199.145 3.505 199.145 3.505 235 3 C118.675 2.505 118.675 2.505 0 2 C0 1.34 0 0.68 0 0 Z " fill="#F1E4D7" transform="translate(396,667)"/>
<path d="M0 0 C0.90234375 -0.01998047 1.8046875 -0.03996094 2.734375 -0.06054688 C6.52988289 -0.05773955 9.22743587 0.02221778 12.6015625 1.8359375 C14.81294304 5.94984655 14.49897414 9.90969449 14.375 14.5 C14.37371094 15.41072266 14.37242187 16.32144531 14.37109375 17.25976562 C14.28148496 24.01527256 14.28148496 24.01527256 12 27.4375 C9.6015625 27.82568359 9.6015625 27.82568359 6.625 27.8359375 C5.02398438 27.84173828 5.02398438 27.84173828 3.390625 27.84765625 C2.27171875 27.83605469 1.1528125 27.82445312 0 27.8125 C-1.11890625 27.82410156 -2.2378125 27.83570313 -3.390625 27.84765625 C-4.45796875 27.84378906 -5.5253125 27.83992187 -6.625 27.8359375 C-7.60726562 27.83255371 -8.58953125 27.82916992 -9.6015625 27.82568359 C-10.39304687 27.69758301 -11.18453125 27.56948242 -12 27.4375 C-14.91351961 23.06722058 -14.36811757 19.07867839 -14.375 13.9375 C-14.39949219 12.97457031 -14.42398438 12.01164063 -14.44921875 11.01953125 C-14.45308594 10.09527344 -14.45695313 9.17101562 -14.4609375 8.21875 C-14.46915527 7.37376953 -14.47737305 6.52878906 -14.48583984 5.65820312 C-14 3.4375 -14 3.4375 -12.26025391 1.66577148 C-8.33674695 -0.46634814 -4.37449317 -0.03437102 0 0 Z M-12 3.4375 C-12.0991657 5.06007544 -12.13079777 6.68689831 -12.1328125 8.3125 C-12.13410156 9.29734375 -12.13539063 10.2821875 -12.13671875 11.296875 C-12.13285156 12.33328125 -12.12898437 13.3696875 -12.125 14.4375 C-12.12886719 15.47390625 -12.13273438 16.5103125 -12.13671875 17.578125 C-12.13542969 18.56296875 -12.13414062 19.5478125 -12.1328125 20.5625 C-12.13168457 21.47257813 -12.13055664 22.38265625 -12.12939453 23.3203125 C-12.23560009 25.4227848 -12.23560009 25.4227848 -11 26.4375 C-9.36527118 26.52450342 -7.72685945 26.54451063 -6.08984375 26.53515625 C-5.10048828 26.53193359 -4.11113281 26.52871094 -3.09179688 26.52539062 C-1.53041992 26.51282227 -1.53041992 26.51282227 0.0625 26.5 C1.10728516 26.49548828 2.15207031 26.49097656 3.22851562 26.48632812 C5.8190666 26.47449913 8.40950559 26.45801689 11 26.4375 C10.67 25.7775 10.34 25.1175 10 24.4375 C3.07 24.4375 -3.86 24.4375 -11 24.4375 C-11 17.8375 -11 11.2375 -11 4.4375 C-10 5.4375 -10 5.4375 -9.90234375 9.25390625 C-9.90901911 10.83596682 -9.92098125 12.41801159 -9.9375 14 C-9.94201172 14.80630859 -9.94652344 15.61261719 -9.95117188 16.44335938 C-9.96299482 18.44143756 -9.98093086 20.43947783 -10 22.4375 C-6.87549468 22.63395093 -3.75038555 22.81813489 -0.625 23 C0.26445313 23.05607422 1.15390625 23.11214844 2.0703125 23.16992188 C2.92109375 23.21826172 3.771875 23.26660156 4.6484375 23.31640625 C5.82672119 23.38710327 5.82672119 23.38710327 7.02880859 23.45922852 C9.07878964 23.58032506 9.07878964 23.58032506 11 22.4375 C11.33 23.0975 11.66 23.7575 12 24.4375 C12 18.1675 12 11.8975 12 5.4375 C11.67 5.4375 11.34 5.4375 11 5.4375 C11 10.7175 11 15.9975 11 21.4375 C10.34 21.4375 9.68 21.4375 9 21.4375 C8.94605295 18.83305652 8.90635531 16.22973002 8.875 13.625 C8.85824219 12.88701172 8.84148438 12.14902344 8.82421875 11.38867188 C8.78925372 7.52503574 8.80602445 5.72846332 11 2.4375 C7.35418313 2.40862488 3.70839537 2.39074415 0.0625 2.375 C-0.97841797 2.36662109 -2.01933594 2.35824219 -3.09179688 2.34960938 C-4.08115234 2.34638672 -5.07050781 2.34316406 -6.08984375 2.33984375 C-7.00628662 2.33460693 -7.92272949 2.32937012 -8.86694336 2.32397461 C-10.98363023 2.19828789 -10.98363023 2.19828789 -12 3.4375 Z " fill="#2A79B7" transform="translate(618,794.5625)"/>
<path d="M0 0 C0.88945312 0.00966797 1.77890625 0.01933594 2.6953125 0.02929688 C3.61570312 0.05443359 4.53609375 0.07957031 5.484375 0.10546875 C6.42023438 0.11900391 7.35609375 0.13253906 8.3203125 0.14648438 C10.62558827 0.18176921 12.9296856 0.23106594 15.234375 0.29296875 C15.564375 1.61296875 15.894375 2.93296875 16.234375 4.29296875 C8.974375 4.29296875 1.714375 4.29296875 -5.765625 4.29296875 C-5.72615789 9.75261395 -5.72615789 9.75261395 -5.67553711 15.2121582 C-5.67303955 15.8949585 -5.67054199 16.57775879 -5.66796875 17.28125 C-5.66273193 17.97774658 -5.65749512 18.67424316 -5.65209961 19.3918457 C-5.765625 21.29296875 -5.765625 21.29296875 -6.765625 24.29296875 C-5.73847839 25.54350005 -5.73847839 25.54350005 -3.54077148 25.40649414 C-2.58243408 25.40125732 -1.62409668 25.39602051 -0.63671875 25.390625 C0.39775391 25.38740234 1.43222656 25.38417969 2.49804688 25.38085938 C3.58666016 25.37248047 4.67527344 25.36410156 5.796875 25.35546875 C6.88935547 25.35095703 7.98183594 25.34644531 9.10742188 25.34179688 C11.81645995 25.32996701 14.52539099 25.31348418 17.234375 25.29296875 C16.904375 25.95296875 16.574375 26.61296875 16.234375 27.29296875 C12.44277203 27.35083543 8.65128189 27.38651929 4.859375 27.41796875 C3.23902344 27.44310547 3.23902344 27.44310547 1.5859375 27.46875 C0.5546875 27.47519531 -0.4765625 27.48164062 -1.5390625 27.48828125 C-2.96871338 27.5039917 -2.96871338 27.5039917 -4.42724609 27.52001953 C-6.765625 27.29296875 -6.765625 27.29296875 -8.765625 25.29296875 C-9.24460942 21.66757997 -9.1360033 18.00892095 -9.140625 14.35546875 C-9.16511719 13.34033203 -9.18960937 12.32519531 -9.21484375 11.27929688 C-9.21871094 10.30541016 -9.22257812 9.33152344 -9.2265625 8.328125 C-9.23478027 7.43488525 -9.24299805 6.54164551 -9.25146484 5.62133789 C-8.765625 3.29296875 -8.765625 3.29296875 -7.03466797 1.49658203 C-4.44395666 0.12233986 -2.91502468 -0.05605817 0 0 Z " fill="#2377BA" transform="translate(400.765625,794.70703125)"/>
<path d="M0 0 C0.33 0 0.66 0 1 0 C1.33 5.94 1.66 11.88 2 18 C2.33 17.01 2.66 16.02 3 15 C3.66 15 4.32 15 5 15 C5 15.66 5 16.32 5 17 C11.27 17 17.54 17 24 17 C24.33 16.01 24.66 15.02 25 14 C25.66 15.98 26.32 17.96 27 20 C24.01351437 22.98648563 21.23452507 22.29249394 17.15039062 22.32226562 C16.13138672 22.31904297 15.11238281 22.31582031 14.0625 22.3125 C13.04736328 22.32861328 12.03222656 22.34472656 10.98632812 22.36132812 C9.52549805 22.36229492 9.52549805 22.36229492 8.03515625 22.36328125 C6.69529663 22.36775269 6.69529663 22.36775269 5.32836914 22.37231445 C3 22 3 22 1.20361328 20.75537109 C-0.32399504 18.52747985 -0.34705208 17.23692008 -0.29296875 14.55078125 C-0.28330078 13.70322266 -0.27363281 12.85566406 -0.26367188 11.98242188 C-0.23853516 11.10134766 -0.21339844 10.22027344 -0.1875 9.3125 C-0.17396484 8.41982422 -0.16042969 7.52714844 -0.14648438 6.60742188 C-0.11114079 4.40433834 -0.06180493 2.20247418 0 0 Z " fill="#2480C8" transform="translate(392,692)"/>
<path d="M0 0 C0.33 0 0.66 0 1 0 C1 5.94 1 11.88 1 18 C6.28 18 11.56 18 17 18 C17 16.35 17 14.7 17 13 C17.33 13 17.66 13 18 13 C18 14.65 18 16.3 18 18 C18.66 18 19.32 18 20 18 C20 12.06 20 6.12 20 0 C20.66 0 21.32 0 22 0 C22.02888514 3.14581423 22.04675923 6.29159469 22.0625 9.4375 C22.07087891 10.33533203 22.07925781 11.23316406 22.08789062 12.15820312 C22.09272461 13.43920898 22.09272461 13.43920898 22.09765625 14.74609375 C22.10551147 15.93223267 22.10551147 15.93223267 22.11352539 17.14233398 C22 19 22 19 21 20 C19.43827255 20.08685603 17.87270656 20.10702219 16.30859375 20.09765625 C15.36435547 20.09443359 14.42011719 20.09121094 13.44726562 20.08789062 C11.95743164 20.07532227 11.95743164 20.07532227 10.4375 20.0625 C9.44041016 20.05798828 8.44332031 20.05347656 7.41601562 20.04882812 C4.94398513 20.03700023 2.47198528 20.01906415 0 20 C0 13.4 0 6.8 0 0 Z " fill="#2E7DBD" transform="translate(607,691)"/>
<path d="M0 0 C0.99 0.66 1.98 1.32 3 2 C3.34057617 4.06298828 3.34057617 4.06298828 3.29296875 6.5703125 C3.28330078 7.46621094 3.27363281 8.36210938 3.26367188 9.28515625 C3.23853516 10.22230469 3.21339844 11.15945313 3.1875 12.125 C3.16719727 13.54232422 3.16719727 13.54232422 3.14648438 14.98828125 C3.11106306 17.32608779 3.06167271 19.66276237 3 22 C-0.08461506 23.02820502 -1.89685239 23.04582849 -5.10546875 22.87890625 C-6.08837891 22.83056641 -7.07128906 22.78222656 -8.08398438 22.73242188 C-9.10814453 22.67634766 -10.13230469 22.62027344 -11.1875 22.5625 C-12.22326172 22.51029297 -13.25902344 22.45808594 -14.32617188 22.40429688 C-16.88442049 22.27461607 -19.44227938 22.13965022 -22 22 C-22 21.34 -22 20.68 -22 20 C-14.74 20 -7.48 20 0 20 C0 13.4 0 6.8 0 0 Z " fill="#3280BF" transform="translate(417,797)"/>
<path d="M0 0 C0.88945312 0.00966797 1.77890625 0.01933594 2.6953125 0.02929688 C3.61570312 0.05443359 4.53609375 0.07957031 5.484375 0.10546875 C6.42023438 0.11900391 7.35609375 0.13253906 8.3203125 0.14648438 C10.62558827 0.18176921 12.9296856 0.23106594 15.234375 0.29296875 C15.564375 0.95296875 15.894375 1.61296875 16.234375 2.29296875 C8.974375 2.29296875 1.714375 2.29296875 -5.765625 2.29296875 C-5.765625 2.95296875 -5.765625 3.61296875 -5.765625 4.29296875 C-5.105625 4.29296875 -4.445625 4.29296875 -3.765625 4.29296875 C-3.765625 6.93296875 -3.765625 9.57296875 -3.765625 12.29296875 C-4.755625 12.78796875 -4.755625 12.78796875 -5.765625 13.29296875 C-6.095625 12.30296875 -6.425625 11.31296875 -6.765625 10.29296875 C-7.095625 14.58296875 -7.425625 18.87296875 -7.765625 23.29296875 C-8.095625 23.29296875 -8.425625 23.29296875 -8.765625 23.29296875 C-8.88171226 20.02243358 -8.95283211 16.75242695 -9.015625 13.48046875 C-9.04914063 12.55169922 -9.08265625 11.62292969 -9.1171875 10.66601562 C-9.13007813 9.77333984 -9.14296875 8.88066406 -9.15625 7.9609375 C-9.17719727 7.13875732 -9.19814453 6.31657715 -9.21972656 5.46948242 C-8.765625 3.29296875 -8.765625 3.29296875 -7.0390625 1.51953125 C-4.44216299 0.11845488 -2.93167445 -0.05637835 0 0 Z " fill="#317AB4" transform="translate(400.765625,740.70703125)"/>
<path d="M0 0 C0.66 0 1.32 0 2 0 C3.20029908 2.40059817 3.11459743 3.89360649 3.09765625 6.5703125 C3.09282227 7.91416016 3.09282227 7.91416016 3.08789062 9.28515625 C3.07951172 10.22230469 3.07113281 11.15945313 3.0625 12.125 C3.05798828 13.06988281 3.05347656 14.01476562 3.04882812 14.98828125 C3.03702357 17.32558387 3.02055759 19.66276061 3 22 C1.02 22.99 1.02 22.99 -1 24 C-0.67 21.69 -0.34 19.38 0 17 C-0.33 17 -0.66 17 -1 17 C-1.02691685 14.54159395 -1.04679802 12.08348118 -1.0625 9.625 C-1.07087891 8.92632813 -1.07925781 8.22765625 -1.08789062 7.5078125 C-1.11328125 2.2265625 -1.11328125 2.2265625 0 0 Z " fill="#307FBE" transform="translate(417,743)"/>
<path d="M0 0 C0.33 0 0.66 0 1 0 C1.33 5.94 1.66 11.88 2 18 C2.33 17.01 2.66 16.02 3 15 C3.66 15 4.32 15 5 15 C5 15.66 5 16.32 5 17 C5.66 17 6.32 17 7 17 C7.33 17.99 7.66 18.98 8 20 C13.28 20.33 18.56 20.66 24 21 C24 21.33 24 21.66 24 22 C20.56265937 22.08703823 17.12560163 22.14040272 13.6875 22.1875 C12.71103516 22.21263672 11.73457031 22.23777344 10.72851562 22.26367188 C9.32182617 22.27817383 9.32182617 22.27817383 7.88671875 22.29296875 C7.02264404 22.3086792 6.15856934 22.32438965 5.26831055 22.34057617 C3 22 3 22 1.21289062 20.75976562 C-0.32474837 18.52882725 -0.34720758 17.24464335 -0.29296875 14.55078125 C-0.28330078 13.70322266 -0.27363281 12.85566406 -0.26367188 11.98242188 C-0.23853516 11.10134766 -0.21339844 10.22027344 -0.1875 9.3125 C-0.17396484 8.41982422 -0.16042969 7.52714844 -0.14648438 6.60742188 C-0.11114079 4.40433834 -0.06180493 2.20247418 0 0 Z " fill="#1A6CAF" transform="translate(392,692)"/>
<path d="M0 0 C0.4846875 0.39832031 0.969375 0.79664063 1.46875 1.20703125 C6.15201331 4.90861052 10.3175548 7.17349976 16 9 C16 9.66 16 10.32 16 11 C8.74750831 10.63122924 8.74750831 10.63122924 5.1875 7.625 C4.465625 6.75875 3.74375 5.8925 3 5 C2.401875 4.443125 1.80375 3.88625 1.1875 3.3125 C0 2 0 2 0 0 Z " fill="#1870B6" transform="translate(379,881)"/>
<path d="M0 0 C0.99 0 1.98 0 3 0 C1.8004855 3.59854349 0.72074777 4.18616815 -2.375 6.25 C-3.16648437 6.79140625 -3.95796875 7.3328125 -4.7734375 7.890625 C-7 9 -7 9 -10 8 C-8.35 7.34 -6.7 6.68 -5 6 C-5 5.01 -5 4.02 -5 3 C-4.01 3 -3.02 3 -2 3 C-1.34 2.01 -0.68 1.02 0 0 Z " fill="#1F75B9" transform="translate(535,786)"/>
<path d="M0 0 C4.43090471 1.20842856 6.98112756 2.51668565 10 6 C9.0925 5.649375 8.185 5.29875 7.25 4.9375 C4.12241564 4.03531221 2.94791919 3.86618493 0 5 C0 3.35 0 1.7 0 0 Z " fill="#2175B9" transform="translate(496,730)"/>
<path d="M0 0 C0.66 0.33 1.32 0.66 2 1 C-0.3125 3.5 -0.3125 3.5 -3 6 C-3.99 6 -4.98 6 -6 6 C-5 3 -5 3 -2.5625 1.25 C-1.716875 0.8375 -0.87125 0.425 0 0 Z " fill="#EEF3F1" transform="translate(509,806)"/>
</svg>

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="1024" height="1536">
<path d="M0 0 C337.92 0 675.84 0 1024 0 C1024 506.88 1024 1013.76 1024 1536 C686.08 1536 348.16 1536 0 1536 C0 1029.12 0 522.24 0 0 Z " fill="#2580C6" transform="translate(0,0)"/>
<path d="M0 0 C22.8408149 5.04824078 47.67745728 5.73685378 70.41796875 -0.5390625 C79.83995866 -2.74809173 88.55043958 -0.38769215 97.8125 1.625 C98.54076996 1.78111755 99.26903992 1.93723511 100.01937866 2.0980835 C112.98683897 4.88988324 125.70328809 8.2274869 138.25 12.5625 C139.46558594 12.98144531 140.68117188 13.40039062 141.93359375 13.83203125 C149.24597052 16.61725501 155.19300048 20.64564156 161.25 25.5625 C162.1059375 26.2534375 162.961875 26.944375 163.84375 27.65625 C174.94353174 37.11161963 181.74226072 48.28975124 187.25 61.5625 C187.57742188 62.32820312 187.90484375 63.09390625 188.2421875 63.8828125 C193.4923946 77.68490334 193.83337178 93.38213529 193.86083984 107.95019531 C193.8750631 110.82525434 193.94286671 113.69610609 194.01171875 116.5703125 C194.16189698 129.31423339 192.74989496 139.21832521 184.00390625 149.1003418 C173.96541109 158.93602709 163.27332368 160.71224983 149.8394165 160.70967102 C148.80397445 160.71334771 147.7685324 160.7170244 146.70171332 160.7208125 C143.23896266 160.73180822 139.77624204 160.73568615 136.31347656 160.73950195 C133.82785955 160.74576958 131.34224348 160.75241664 128.85662842 160.75941467 C121.40522306 160.77860445 113.95382568 160.78923556 106.5024029 160.79814246 C102.98825215 160.80253359 99.47410289 160.80789809 95.95995331 160.81313133 C84.27082253 160.83013422 72.58169266 160.84462836 60.89255142 160.85188007 C57.86358896 160.85378976 54.8346265 160.85571013 51.80566406 160.85766602 C51.05289184 160.85814976 50.30011961 160.8586335 49.5245361 160.8591319 C37.33002996 160.86743239 25.13562189 160.89277382 12.94115856 160.92524669 C0.40565432 160.95835431 -12.12980011 160.976332 -24.66534829 160.97952431 C-31.69734812 160.98168551 -38.72920472 160.99037733 -45.7611618 161.01598549 C-52.37679541 161.04002309 -58.99221809 161.04349505 -65.60790253 161.03322411 C-68.03180803 161.03272894 -70.45572626 161.03904366 -72.8795929 161.05278015 C-76.1971959 161.07052541 -79.51387745 161.06337919 -82.83148193 161.05024719 C-83.78296671 161.06121644 -84.73445148 161.07218568 -85.71476912 161.08348733 C-97.28571368 160.97345558 -106.33745992 156.65374371 -114.62109375 148.78515625 C-122.87815141 140.10192788 -125.56808015 130.91017269 -125.71484375 119.09570312 C-125.74085119 117.22172244 -125.78533714 115.34797774 -125.83984375 113.47460938 C-126.56848275 88.13254486 -124.45511471 61.58864947 -108.75 40.5625 C-108.07066406 39.63953125 -107.39132812 38.7165625 -106.69140625 37.765625 C-87.67994353 13.23033265 -57.89238736 4.51388887 -28.5625 -0.875 C-27.64364014 -1.0510376 -26.72478027 -1.2270752 -25.77807617 -1.40844727 C-16.92165654 -2.92822588 -8.71183823 -2.0912746 0 0 Z " fill="#E8E9E8" transform="translate(478.75,787.4375)"/>
<path d="M0 0 C1.73926758 -0.02416992 1.73926758 -0.02416992 3.51367188 -0.04882812 C11.05326462 -0.05306623 17.54845279 0.90261311 24.6875 3.3125 C25.69478149 3.64109009 25.69478149 3.64109009 26.72241211 3.97631836 C51.1577388 12.33242353 68.95555946 29.3211058 80.70703125 52.06640625 C89.99279611 71.12852136 90.5524373 94.67822982 83.88916016 114.71289062 C80.20194717 124.90518668 75.74492884 134.00251222 68.6875 142.3125 C68.01203125 143.15941406 67.3365625 144.00632812 66.640625 144.87890625 C53.02515593 161.1528702 30.88408407 171.86067213 9.83984375 174.14697266 C-13.03616352 175.90626632 -35.99007096 173.21679522 -54.3125 158.3125 C-55.13105469 157.66796875 -55.94960938 157.0234375 -56.79296875 156.359375 C-73.74985885 142.16290887 -84.32301269 120.17499797 -86.3125 98.3125 C-87.78882664 74.41477109 -82.88756518 52.05488254 -67.3125 33.3125 C-66.6009375 32.45011719 -65.889375 31.58773437 -65.15625 30.69921875 C-48.59757151 11.48765339 -25.3694602 -0.06981139 0 0 Z " fill="#E8E9E8" transform="translate(512.3125,583.6875)"/>
<path d="M0 0 C2 3 2 3 2 6 C2.66 6 3.32 6 4 6 C4 12.6 4 19.2 4 26 C3.34 26 2.68 26 2 26 C1.67 23.03 1.34 20.06 1 17 C0.67 20.3 0.34 23.6 0 27 C-0.33 27 -0.66 27 -1 27 C-1.02922646 23.62501842 -1.04687468 20.25006979 -1.0625 16.875 C-1.07087891 15.92367187 -1.07925781 14.97234375 -1.08789062 13.9921875 C-1.10449608 9.20981513 -1.00537055 4.70228431 0 0 Z " fill="#DEEBEF" transform="translate(427,655)"/>
<path d="M0 0 C4.85308929 0.03516731 9.01599057 1.50621855 13.5 3.25 C13.5 3.58 13.5 3.91 13.5 4.25 C12.39140625 4.23839844 11.2828125 4.22679687 10.140625 4.21484375 C8.67708502 4.20546208 7.21354322 4.19636229 5.75 4.1875 C5.02039063 4.17912109 4.29078125 4.17074219 3.5390625 4.16210938 C0.00175151 4.14603069 -3.09478714 4.34944784 -6.5 5.25 C-7.91534778 5.31456879 -9.33337689 5.33611039 -10.75 5.3125 C-12.60625 5.2815625 -12.60625 5.2815625 -14.5 5.25 C-14.17 3.93 -13.84 2.61 -13.5 1.25 C-12.86191406 1.26160156 -12.22382812 1.27320313 -11.56640625 1.28515625 C-10.74011719 1.29417969 -9.91382813 1.30320312 -9.0625 1.3125 C-7.82693359 1.32990234 -7.82693359 1.32990234 -6.56640625 1.34765625 C-3.65374625 1.21000691 -3.3658036 0.24041454 0 0 Z " fill="#1773B8" transform="translate(555.5,782.75)"/>
<path d="M0 0 C3.875 2.75 3.875 2.75 5 5 C4.10861328 4.90912109 4.10861328 4.90912109 3.19921875 4.81640625 C1.43527931 4.638092 -0.3290878 4.46398236 -2.09375 4.29296875 C-3.94988796 4.10585807 -5.8044483 3.90180999 -7.65625 3.67578125 C-14.88380508 2.85736692 -21.77495046 3.25337326 -29 4 C-20.28548781 -0.6401948 -9.47344505 0.59652666 0 2 C0 1.34 0 0.68 0 0 Z " fill="#1B6FB0" transform="translate(477,783)"/>
<path d="M0 0 C1.16666667 0.875 2.33333333 1.75 3.5 2.625 C4.23089844 3.1715625 4.96179688 3.718125 5.71484375 4.28125 C7.17171869 5.37701919 8.62290325 6.48042444 10.06640625 7.59375 C14.22199472 10.78159869 18.39324645 13.50467516 23 16 C20.55434637 16.88982487 19.43632392 17.18663074 17 16.14453125 C16.34 15.70496094 15.68 15.26539063 15 14.8125 C14.30132812 14.35101562 13.60265625 13.88953125 12.8828125 13.4140625 C11 12 11 12 8.875 9.6875 C7.0153803 7.66881881 7.0153803 7.66881881 3.75 7.75 C2.8425 7.8325 1.935 7.915 1 8 C1.33 6.68 1.66 5.36 2 4 C1.34 4 0.68 4 0 4 C0 2.68 0 1.36 0 0 Z " fill="#1C72B4" transform="translate(449,734)"/>
<path d="M0 0 C0.78454486 2.9628839 1.13527511 5.55274296 1.125 8.625 C1.12757813 9.40101563 1.13015625 10.17703125 1.1328125 10.9765625 C1 13 1 13 0 15 C-0.61875 15.268125 -1.2375 15.53625 -1.875 15.8125 C-4.64126132 17.35835192 -5.54655436 19.23845328 -7 22 C-8 20 -8 20 -7.37915039 17.93701172 C-7.03875732 17.10959473 -6.69836426 16.28217773 -6.34765625 15.4296875 C-5.80141602 14.08583984 -5.80141602 14.08583984 -5.24414062 12.71484375 C-4.65922852 11.30912109 -4.65922852 11.30912109 -4.0625 9.875 C-3.67642578 8.93011719 -3.29035156 7.98523438 -2.89257812 7.01171875 C-1.93524154 4.67156265 -0.97080009 2.33459572 0 0 Z " fill="#1F76B7" transform="translate(597,693)"/>
<path d="M0 0 C-3.06907968 2.04605312 -5.86083443 3.1595477 -9.3125 4.4375 C-12.75857792 5.71426987 -15.98491355 6.99195511 -19.23046875 8.72265625 C-22 10 -22 10 -25 9 C-23.71086513 8.32845067 -22.41859796 7.66291141 -21.125 7 C-20.40570313 6.62875 -19.68640625 6.2575 -18.9453125 5.875 C-17 5 -17 5 -15 5 C-15 4.34 -15 3.68 -15 3 C-11.535 2.505 -11.535 2.505 -8 2 C-8 1.34 -8 0.68 -8 0 C-4.71023192 -1.09658936 -3.28696233 -0.79953138 0 0 Z " fill="#2076B8" transform="translate(494,585)"/>
<path d="M0 0 C0.33 0.66 0.66 1.32 1 2 C1.66 2 2.32 2 3 2 C2.8125 4.3125 2.8125 4.3125 2 7 C-0.4375 8.875 -0.4375 8.875 -3 10 C-3.66 9.67 -4.32 9.34 -5 9 C-3.50163895 5.88801935 -1.80066242 2.9465385 0 0 Z " fill="#2178BB" transform="translate(588,715)"/>
<path d="M0 0 C-0.99 0.495 -0.99 0.495 -2 1 C-2.65213292 3.02463255 -2.65213292 3.02463255 -3 5 C-5.64 4.67 -8.28 4.34 -11 4 C-10.67 3.01 -10.34 2.02 -10 1 C-6.34742296 -0.62336757 -3.80637601 -1.46399077 0 0 Z " fill="#1F75B8" transform="translate(549,752)"/>
<path d="M0 0 C0.99 1.0209375 0.99 1.0209375 2 2.0625 C5.49468234 5.56725347 9.25130779 8.77221788 13 12 C11.68 12.33 10.36 12.66 9 13 C7.79557292 11.4375 6.59114583 9.875 5.38671875 8.3125 C3.95508863 6.65629795 3.95508863 6.65629795 1 7 C0.67 4.69 0.34 2.38 0 0 Z " fill="#2174B4" transform="translate(359,930)"/>
<path d="M0 0 C-4.95 2.475 -4.95 2.475 -10 5 C-9.67 5.66 -9.34 6.32 -9 7 C-11.97 6.505 -11.97 6.505 -15 6 C-12.87748883 3.69895986 -10.84451444 2.18105541 -8.0625 0.75 C-7.39347656 0.39421875 -6.72445313 0.0384375 -6.03515625 -0.328125 C-4 -1 -4 -1 0 0 Z " fill="#E3F0F3" transform="translate(484,591)"/>
<path d="M0 0 C4.62 0 9.24 0 14 0 C10.2311658 1.8844171 8.31987525 2.33429462 4.25 2.625 C3.26515625 2.69976563 2.2803125 2.77453125 1.265625 2.8515625 C0.51796875 2.90054688 -0.2296875 2.94953125 -1 3 C-0.67 2.01 -0.34 1.02 0 0 Z " fill="#1874BA" transform="translate(433,787)"/>
<path d="M0 0 C4.82360189 -0.19294408 8.49646738 0.24252386 13 2 C13 2.66 13 3.32 13 4 C11.01926635 3.719172 9.04041552 3.42503386 7.0625 3.125 C5.40927734 2.88136719 5.40927734 2.88136719 3.72265625 2.6328125 C2.82417969 2.42398438 1.92570313 2.21515625 1 2 C0.67 1.34 0.34 0.68 0 0 Z " fill="#2D7AB5" transform="translate(580,789)"/>
<path d="M0 0 C0.33 0 0.66 0 1 0 C1.33 4.29 1.66 8.58 2 13 C1.34 12.34 0.68 11.68 0 11 C0 9.68 0 8.36 0 7 C-0.66 7 -1.32 7 -2 7 C-1.34 4.69 -0.68 2.38 0 0 Z " fill="#2879B7" transform="translate(426,682)"/>
<path d="M0 0 C2 2 2 2 2 5 C3.32 4.67 4.64 4.34 6 4 C5.34 6.64 4.68 9.28 4 12 C2.09681116 9.14521674 1.5380901 7.58058692 0.875 4.3125 C0.70742188 3.50425781 0.53984375 2.69601562 0.3671875 1.86328125 C0.24601563 1.24839844 0.12484375 0.63351562 0 0 Z " fill="#1E72B4" transform="translate(595,640)"/>
<path d="M0 0 C0.99 0 1.98 0 3 0 C2.01 3.3 1.02 6.6 0 10 C-0.66 10 -1.32 10 -2 10 C-1.855625 9.46375 -1.71125 8.9275 -1.5625 8.375 C-0.90546252 5.60084173 -0.44780052 2.81474612 0 0 Z " fill="#2379B9" transform="translate(360,839)"/>
<path d="M0 0 C0.66 0.99 1.32 1.98 2 3 C0.75 4.5625 0.75 4.5625 -1 6 C-3.1875 5.6875 -3.1875 5.6875 -5 5 C-3.35 3.35 -1.7 1.7 0 0 Z " fill="#1974B8" transform="translate(664,934)"/>
<path d="M0 0 C2 2 2 2 2.1953125 4.3828125 C2.17210937 5.28773438 2.14890625 6.19265625 2.125 7.125 C2.10695313 8.03507813 2.08890625 8.94515625 2.0703125 9.8828125 C2.03550781 10.93082031 2.03550781 10.93082031 2 12 C1.67 12 1.34 12 1 12 C0.855625 11.236875 0.71125 10.47375 0.5625 9.6875 C0.0367406 6.91827092 0.0367406 6.91827092 -1 4 C-0.5625 1.8125 -0.5625 1.8125 0 0 Z " fill="#EEF2F0" transform="translate(668,862)"/>
<path d="M0 0 C-2 2 -2 2 -4.3828125 2.1953125 C-5.28773438 2.17210937 -6.19265625 2.14890625 -7.125 2.125 C-8.49011719 2.09792969 -8.49011719 2.09792969 -9.8828125 2.0703125 C-10.58148438 2.04710937 -11.28015625 2.02390625 -12 2 C-7.57703297 -0.94864468 -5.15145854 -1.28786464 0 0 Z " fill="#D3E6F0" transform="translate(500,586)"/>
<path d="M0 0 C-2.64738916 1.4606285 -3.89448334 2 -7 2 C-7 2.66 -7 3.32 -7 4 C-7.99 4 -8.98 4 -10 4 C-10 3.01 -10 2.02 -10 1 C-6.59437148 0.22157062 -3.49234244 -0.09978121 0 0 Z " fill="#1880CC" transform="translate(429,790)"/>
<path d="M0 0 C1.9375 0.6875 1.9375 0.6875 4 2 C4.75 4.625 4.75 4.625 5 7 C2.5 5.1875 2.5 5.1875 0 3 C0 2.01 0 1.02 0 0 Z " fill="#1F7ABE" transform="translate(576,609)"/>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -0,0 +1,265 @@
import type { Models } from "@nontara/server/models";
import { useQuery } from "@tanstack/react-query";
import { Link, useLocation, useRouter } from "@tanstack/react-router";
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 logoSrc from "@/assets/logo.png";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { http } 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 http.get<Models.API.LibraryAllOutput>("library/all");
return response.data;
},
});
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/$id"
params={{
id: 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 router = useRouter();
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={() => router.history.back()}
>
<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

@@ -0,0 +1,11 @@
import logo from "../assets/logo.svg";
export default function Preloader() {
return (
<div id="preloader" aria-live="polite" aria-busy="true">
<img className="logo" src={logo} alt="Nontara Logo" />
{/* biome-ignore lint/a11y/useSemanticElements: role="status" is semantically correct for loading spinner */}
<div className="spinner" role="status" aria-label="Loading" />
</div>
);
}

View File

@@ -0,0 +1,157 @@
import { useForm } from "@tanstack/react-form";
import { Link, useNavigate } from "@tanstack/react-router";
import { useEffect, useEffectEvent } from "react";
import { toast } from "sonner";
import z from "zod";
import { authClient } from "@/lib/auth-client";
// import Loader from "./loader";
import { Button } from "./ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "./ui/card";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
export default function SignInForm() {
const navigate = useNavigate();
const { isPending, data } = authClient.useSession();
const onRendered = useEffectEvent(() => {
if (data) {
navigate({ to: "/home" });
}
});
useEffect(() => {
onRendered();
}, []);
const form = useForm({
defaultValues: {
email: "",
password: "",
},
onSubmit: async ({ value }) => {
await authClient.signIn.email(
{
email: value.email,
password: value.password,
},
{
onSuccess: () => {
navigate({ to: "/home" });
toast.success("Sign in successful");
},
onError: (error) => {
toast.error(error.error.message || error.error.statusText);
},
},
);
},
validators: {
onSubmit: z.object({
email: z.email(),
password: z.string().min(8, "Password must be at least 8 characters"),
}),
},
});
if (isPending) {
return <span>Loading...</span>;
}
return (
<Card className="mx-auto w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="font-bold text-2xl">Welcome to Nontara</CardTitle>
<CardDescription className="text-base">
Sign in to access your media library and start streaming
</CardDescription>
</CardHeader>
<CardContent>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
className="space-y-4"
>
<div>
<form.Field name="email">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Email</Label>
<Input
id={field.name}
name={field.name}
autoComplete="email"
type="email"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-red-500 text-sm">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<div>
<form.Field name="password">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Password</Label>
<Input
id={field.name}
name={field.name}
autoComplete="current-password"
type="password"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-red-500 text-sm">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<form.Subscribe>
{(state) => (
<Button
type="submit"
className="w-full"
disabled={!state.canSubmit || state.isSubmitting}
>
{state.isSubmitting ? "Submitting..." : "Sign In"}
</Button>
)}
</form.Subscribe>
</form>
</CardContent>
<CardFooter className="justify-center">
<p className="text-muted-foreground text-sm">
Don't have an account?{" "}
<Link
to="/signup"
className="font-medium text-primary underline-offset-4 hover:underline"
>
Sign up
</Link>
</p>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,219 @@
import { useForm } from "@tanstack/react-form";
import { Link, useNavigate } from "react-router";
import { toast } from "sonner";
import z from "zod";
import { authClient } from "@/lib/auth-client";
// import Loader from "./loader";
import { Button } from "./ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "./ui/card";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
export default function SignUpForm() {
const navigate = useNavigate();
const { isPending } = authClient.useSession();
const form = useForm({
defaultValues: {
email: "",
username: "",
password: "",
confirmPassword: "",
},
onSubmit: async ({ value }) => {
await authClient.signUp.email(
{
email: value.email,
name: value.username,
username: value.username,
password: value.password,
},
{
onSuccess: () => {
navigate("/home");
toast.success("Account created successfully");
},
onError: (error) => {
toast.error(error.error.message || error.error.statusText);
},
},
);
},
validators: {
onSubmit: z
.object({
email: z.string().email("Invalid email address"),
username: z
.string()
.min(3, "Username must be at least 3 characters")
.max(20, "Username must be less than 20 characters")
.regex(
/^[a-zA-Z0-9_]+$/,
"Username can only contain letters, numbers, and underscores",
),
password: z.string().min(8, "Password must be at least 8 characters"),
confirmPassword: z.string(),
})
.superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: "custom",
message: "Passwords must match",
path: ["confirmPassword"],
});
}
}),
},
});
if (isPending) {
return <span>Loading...</span>;
}
return (
<Card className="mx-auto w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="font-bold text-2xl">Join Nontara</CardTitle>
<CardDescription className="text-base">
Create your account to build your personal media library
</CardDescription>
</CardHeader>
<CardContent>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
className="space-y-4"
>
<div>
<form.Field name="email">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Email</Label>
<Input
id={field.name}
name={field.name}
type="email"
placeholder="Enter your email"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-red-500 text-sm">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<div>
<form.Field name="username">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Username</Label>
<Input
id={field.name}
name={field.name}
type="text"
placeholder="Choose a username"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-red-500 text-sm">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<div>
<form.Field name="password">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Password</Label>
<Input
id={field.name}
name={field.name}
type="password"
placeholder="Create a password"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-red-500 text-sm">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<div>
<form.Field name="confirmPassword">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Confirm Password</Label>
<Input
id={field.name}
name={field.name}
type="password"
placeholder="Confirm your password"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-red-500 text-sm">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<form.Subscribe>
{(state) => (
<Button
type="submit"
className="w-full"
disabled={!state.canSubmit || state.isSubmitting}
>
{state.isSubmitting ? "Creating Account..." : "Create Account"}
</Button>
)}
</form.Subscribe>
</form>
</CardContent>
<CardFooter className="justify-center">
<p className="text-muted-foreground text-sm">
Already have an account?{" "}
<Link
to="/login"
className="font-medium text-primary underline-offset-4 hover:underline"
>
Sign in
</Link>
</p>
</CardFooter>
</Card>
);
}

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 { Link, useNavigate } from "@tanstack/react-router";
import { LayoutDashboard, LogOutIcon } from "lucide-react";
import React from "react";
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({ to: "/" });
},
},
});
}}
>
<LogOutIcon className="text-red-600!" />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,81 @@
import { useLocation, useRouter } from "@tanstack/react-router";
import type { LucideIcon } from "lucide-react";
import {
ArrowLeft,
Database,
LayoutDashboard,
Puzzle,
Users,
} from "lucide-react";
import UserMenu from "@/components/UserMenu";
import { Button } from "@/components/ui/button";
import { SidebarTrigger } from "@/components/ui/sidebar";
import type { FileRoutesByTo } from "@/routeTree.gen";
type DashboardRoute = Extract<
keyof FileRoutesByTo,
| "/dashboard"
| "/dashboard/users"
| "/dashboard/libraries"
| "/dashboard/extensions"
>;
interface MenuItem {
readonly title: string;
readonly url: DashboardRoute;
readonly icon: LucideIcon;
}
const menuItems: readonly MenuItem[] = [
{
title: "Dashboard",
url: "/dashboard",
icon: LayoutDashboard,
},
{
title: "Users",
url: "/dashboard/users",
icon: Users,
},
{
title: "Libraries",
url: "/dashboard/libraries",
icon: Database,
},
{
title: "Extensions",
url: "/dashboard/extensions",
icon: Puzzle,
},
];
export function DashboardHeader() {
const router = useRouter();
const location = useLocation();
const pathname = location.pathname;
const getHeaderTitle = () => {
const menuItem = menuItems.find((item) => item.url === pathname);
return menuItem?.title || "Dashboard";
};
return (
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
<div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" />
<Button
variant="ghost"
size="icon"
onClick={() => router.history.back()}
aria-label="Go back"
>
<ArrowLeft className="size-5" />
</Button>
</div>
<div className="flex flex-1 items-center justify-between">
<h1 className="font-semibold text-lg">{getHeaderTitle()}</h1>
<UserMenu />
</div>
</header>
);
}

View File

@@ -0,0 +1,132 @@
import { useNavigate } from "@tanstack/react-router";
import { ChevronUp, LogOut, Settings, User } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SidebarMenuButton } from "@/components/ui/sidebar";
import { Skeleton } from "@/components/ui/skeleton";
import { authClient } from "@/lib/auth-client";
export function UserAccountDropdown() {
const navigate = useNavigate();
const { data: session, isPending } = authClient.useSession();
if (isPending) {
return (
<div className="flex items-center gap-2 px-2 py-1.5">
<Skeleton className="size-8 rounded-lg" />
<div className="flex flex-1 flex-col gap-1">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-32" />
</div>
</div>
);
}
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>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<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>
<ChevronUp className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side="top"
align="end"
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>
<User />
Profile
</DropdownMenuItem>
<DropdownMenuItem>
<Settings />
Settings
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600!"
onClick={() => {
authClient.signOut({
fetchOptions: {
onSuccess: () => {
navigate({
to: "/",
});
},
},
});
}}
>
<LogOut className="text-red-600!" />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,42 @@
import { ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import PersonCard from "./PersonCard";
type CastMember = {
id: string;
name: string;
character: string | null;
profilePicture: string | null;
order: number | null;
};
type CastSectionProps = {
cast: CastMember[];
};
export default function CastSection({ cast }: CastSectionProps) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-lg">Cast</h3>
<Button
variant="ghost"
size="sm"
className="text-primary hover:text-primary/80"
>
View All <ChevronRight className="ml-1 h-4 w-4" />
</Button>
</div>
<div className="no-scrollbar flex gap-4 overflow-x-auto pb-2">
{cast.map((actor) => (
<PersonCard
key={actor.id}
name={actor.name}
role={actor.character ?? "Unknown"}
imageUrl={actor.profilePicture ?? "/people-placeholder.svg"}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import { ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import PersonCard from "./PersonCard";
type CrewMember = {
id: string;
name: string;
job: string | null;
department: string | null;
profilePicture: string | null;
order: number | null;
};
type CrewSectionProps = {
crew: CrewMember[];
};
export default function CrewSection({ crew }: CrewSectionProps) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-lg">Crew</h3>
<Button
variant="ghost"
size="sm"
className="text-primary hover:text-primary/80"
>
View All <ChevronRight className="ml-1 h-4 w-4" />
</Button>
</div>
<div className="no-scrollbar flex gap-4 overflow-x-auto pb-2">
{crew.map((member) => {
const role = member.job
? member.department
? `${member.job} (${member.department})`
: member.job
: "Unknown";
return (
<PersonCard
key={member.id}
name={member.name}
role={role}
imageUrl={member.profilePicture ?? "/people-placeholder.svg"}
/>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import type { Models } from "@nontara/server/models";
import { Card } from "@/components/ui/card";
type EpisodeCardProps = {
episode: Models.GetSeasonEpisodesOutput["episodes"][number];
};
function formatDuration(seconds: number | null): string {
if (!seconds) return "N/A";
const minutes = Math.floor(seconds / 60);
return `${minutes}m`;
}
export function EpisodeCard({ episode }: EpisodeCardProps) {
return (
<Card className="cursor-pointer border-border/50 bg-card/50 backdrop-blur-sm transition-colors hover:bg-card/70">
<div className="flex gap-4 p-4">
{/* Episode Number */}
<div className="flex w-12 flex-shrink-0 items-center justify-center">
<span className="font-semibold text-2xl text-muted-foreground">
{episode.episodeNumber}
</span>
</div>
{/* Thumbnail */}
<div className="flex-shrink-0">
<img
src={episode.backgroundUrl ?? "/movie-placeholder.svg"}
alt={episode.title}
className="h-20 w-36 rounded object-cover"
/>
</div>
{/* Episode Info */}
<div className="flex min-w-0 flex-1 flex-col justify-center gap-2">
<h3 className="font-medium text-base">{episode.title}</h3>
<p className="line-clamp-2 text-muted-foreground text-sm leading-relaxed">
{episode.overview || "No description available"}
</p>
</div>
{/* Duration */}
<div className="flex flex-shrink-0 items-center">
<span className="text-muted-foreground text-sm">
{formatDuration(episode.runtimeSeconds)}
</span>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,82 @@
import type { Models } from "@nontara/server/models";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { http } from "@/lib/api";
import { EpisodeCard } from "./EpisodeCard";
type EpisodesListProps = {
titleId: string;
seasons: Models.SeriesSeason[];
};
export function EpisodesList({ titleId, seasons }: EpisodesListProps) {
const [selectedSeason, setSelectedSeason] = useState(
seasons[0]?.seasonId || "1",
);
const { data: episodesData, isLoading } = useQuery({
queryKey: ["episodes", titleId, selectedSeason],
queryFn: async () => {
const response = await http.get<Models.API.TitleSeasonsOutput>(
`title/${titleId}/seasons/${selectedSeason}`,
);
return response.data;
},
});
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="font-semibold text-2xl">Episodes</h2>
<Select value={selectedSeason} onValueChange={setSelectedSeason}>
<SelectTrigger className="w-[180px] border-border/50 bg-card/50">
<SelectValue placeholder="Select season" />
</SelectTrigger>
<SelectContent>
{seasons.map((season) => (
<SelectItem key={season.seasonId} value={season.seasonId}>
{season.title ? (
<>
S{String(season.seasonNumber).padStart(2, "0")}:{" "}
<span className="text-muted-foreground">
{season.title}
</span>
</>
) : (
`Season ${season.seasonNumber}`
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Episodes List */}
<div className="space-y-4">
{isLoading ? (
<div className="flex items-center justify-center p-8">
<p className="text-muted-foreground text-sm">Loading episodes...</p>
</div>
) : episodesData?.episodes.length === 0 ? (
<div className="flex items-center justify-center p-8">
<p className="text-muted-foreground text-sm">
No episodes available
</p>
</div>
) : (
episodesData?.episodes.map((episode) => (
<EpisodeCard key={episode.id} episode={episode} />
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,282 @@
import { findBy } from "@nontara/language-codes";
import type { Models } from "@nontara/server/models";
import { useQuery } from "@tanstack/react-query";
import { FileVideo, Languages, Volume2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Card } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { http } from "@/lib/api";
type MediaTracksSelectorProps = {
titleId: string;
onTracksChange?: (tracks: {
video?: number;
audio?: number;
subtitle?: number;
}) => void;
};
export default function MediaTracksSelector({
titleId,
onTracksChange,
}: MediaTracksSelectorProps) {
const { data, isLoading, error } = useQuery({
queryKey: ["media-tracks", titleId],
queryFn: async () => {
const response = await http.get<Models.API.MediaTracksOutput>(
"/media/tracks",
{
params: {
titleId,
},
},
);
return response.data;
},
});
const [selectedVideo, setSelectedVideo] = useState<string>("");
const [selectedAudio, setSelectedAudio] = useState<string>("");
const [selectedSubtitle, setSelectedSubtitle] = useState<string>("");
// Set default selections when data loads
useEffect(() => {
if (!data || data.length === 0) return;
const mediaItem = data[0];
// Set default video track (first one)
if (mediaItem.tracks.video.length > 0 && !selectedVideo) {
setSelectedVideo(mediaItem.tracks.video[0].streamIndex.toString());
}
// Set default audio track (default or first one)
if (mediaItem.tracks.audio.length > 0 && !selectedAudio) {
const defaultAudio = mediaItem.tracks.audio.find((t) => t.isDefault);
const audioToSelect = defaultAudio || mediaItem.tracks.audio[0];
setSelectedAudio(audioToSelect.streamIndex.toString());
}
// Set default subtitle track if there's a default one
if (mediaItem.tracks.subtitle.length > 0 && !selectedSubtitle) {
const defaultSubtitle = mediaItem.tracks.subtitle.find(
(t) => t.isDefault,
);
if (defaultSubtitle) {
setSelectedSubtitle(defaultSubtitle.streamIndex.toString());
}
}
}, [data, selectedVideo, selectedAudio, selectedSubtitle]);
// Notify parent of track changes
useEffect(() => {
if (onTracksChange) {
onTracksChange({
video: selectedVideo ? Number.parseInt(selectedVideo, 10) : undefined,
audio: selectedAudio ? Number.parseInt(selectedAudio, 10) : undefined,
subtitle:
selectedSubtitle && selectedSubtitle !== "none"
? Number.parseInt(selectedSubtitle, 10)
: undefined,
});
}
}, [selectedVideo, selectedAudio, selectedSubtitle, onTracksChange]);
// Memoized language name lookup to avoid unnecessary re-renders
const languageNames = useMemo(() => {
const cache = new Map<string, string>();
const getLanguageName = (code: string | null): string | null => {
if (!code) return null;
// Check cache first
const cached = cache.get(code);
if (cached) {
return cached;
}
const lowerCode = code.toLowerCase().trim();
// Try to find language by code length
let language: ReturnType<typeof findBy> | undefined;
if (lowerCode.length === 3) {
// Try ISO 639-3 first, then ISO 639-2
language = findBy("3", lowerCode) || findBy("2", lowerCode);
} else if (lowerCode.length === 2) {
// Try ISO 639-1 first, then ISO 639-2
language = findBy("1", lowerCode) || findBy("2", lowerCode);
}
// Get name or fallback to uppercase code
const name = language?.name || code.toUpperCase();
cache.set(code, name);
return name;
};
return getLanguageName;
}, []);
// If loading or error, don't render anything
if (isLoading || error || !data || data.length === 0) {
return null;
}
// Get the first media item (usually there's only one)
const mediaItem = data[0];
const formatVideoTrack = (
track: (typeof mediaItem.tracks.video)[number],
): string => {
const parts = [];
if (track.width && track.height) {
parts.push(`${track.width}x${track.height}`);
}
if (track.codecName) {
parts.push(track.codecName.toUpperCase());
}
return parts.length > 0 ? parts.join(" • ") : `Stream ${track.streamIndex}`;
};
const formatAudioTrack = (
track: (typeof mediaItem.tracks.audio)[number],
): string => {
const parts = [];
if (track.language) {
const langName = languageNames(track.language);
if (langName) {
parts.push(langName);
}
}
if (track.title) {
parts.push(track.title);
}
if (track.channels) {
parts.push(`${track.channels}ch`);
}
if (track.codecName) {
parts.push(track.codecName.toUpperCase());
}
if (track.isDefault) {
parts.push("(Default)");
}
return parts.length > 0 ? parts.join(" • ") : `Stream ${track.streamIndex}`;
};
const formatSubtitleTrack = (
track: (typeof mediaItem.tracks.subtitle)[number],
): string => {
const parts = [];
if (track.language) {
const langName = languageNames(track.language);
if (langName) {
parts.push(langName);
}
}
if (track.title) {
parts.push(track.title);
}
if (track.isForced) {
parts.push("(Forced)");
}
if (track.isDefault) {
parts.push("(Default)");
}
return parts.length > 0 ? parts.join(" • ") : `Stream ${track.streamIndex}`;
};
return (
<div className="space-y-3 sm:space-y-4">
<h2 className="font-semibold text-lg sm:text-xl">Media Tracks</h2>
<Card className="border-border/50 bg-card/50 p-4 backdrop-blur-sm sm:p-6">
<div className="flex flex-col gap-4">
{/* Video Tracks */}
{mediaItem.tracks.video.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 font-medium text-muted-foreground text-sm">
<FileVideo className="h-4 w-4" />
Video Track
</div>
<Select value={selectedVideo} onValueChange={setSelectedVideo}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select video track" />
</SelectTrigger>
<SelectContent>
{mediaItem.tracks.video.map((track) => (
<SelectItem
key={track.streamIndex}
value={track.streamIndex.toString()}
>
{formatVideoTrack(track)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Audio Tracks */}
{mediaItem.tracks.audio.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 font-medium text-muted-foreground text-sm">
<Volume2 className="h-4 w-4" />
Audio Track
</div>
<Select value={selectedAudio} onValueChange={setSelectedAudio}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select audio track" />
</SelectTrigger>
<SelectContent>
{mediaItem.tracks.audio.map((track) => (
<SelectItem
key={track.streamIndex}
value={track.streamIndex.toString()}
>
{formatAudioTrack(track)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Subtitle Tracks */}
{mediaItem.tracks.subtitle.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 font-medium text-muted-foreground text-sm">
<Languages className="h-4 w-4" />
Subtitle Track
</div>
<Select
value={selectedSubtitle}
onValueChange={setSelectedSubtitle}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="No subtitle" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No subtitle</SelectItem>
{mediaItem.tracks.subtitle.map((track) => (
<SelectItem
key={track.streamIndex}
value={track.streamIndex.toString()}
>
{formatSubtitleTrack(track)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { Link } from "@tanstack/react-router";
type MovieCardProps = {
id: string;
imageUrl?: string;
title: string;
description: string | number;
};
export default function MovieCard({
id,
title,
imageUrl,
description,
}: MovieCardProps) {
return (
<div className="flex h-full min-w-36 max-w-36 flex-1 flex-col gap-2 rounded-lg p-0 transition-transform duration-200 hover:scale-105 hover:shadow-lg md:min-w-48 md:max-w-48 md:p-2">
<Link to="/home/details/$id" params={{ id }}>
<div className="relative aspect-[3/4] w-full overflow-hidden rounded-lg bg-center bg-cover bg-no-repeat">
<img
src={
imageUrl && imageUrl.length > 10
? imageUrl
: "/movie-placeholder.svg"
}
alt={title}
className="rounded-sm"
/>
<div className="absolute right-0 bottom-0 left-0 bg-gradient-to-t from-black/70 to-transparent p-2 md:hidden">
<p className="truncate text-foreground text-xs leading-normal">
{title}
</p>
</div>
</div>
</Link>
<div className="hidden md:flex md:flex-col md:gap-1">
<p className="truncate px-1 text-center text-foreground text-sm leading-normal md:text-base">
{title}
</p>
<div className="flex items-center justify-center px-1 text-muted-foreground text-xs">
<span className="text-center">{description}</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
type PersonCardProps = {
name: string;
role: string;
imageUrl: string;
};
export default function PersonCard({ name, role, imageUrl }: PersonCardProps) {
return (
<div className="w-24 flex-shrink-0 space-y-2">
<div className="relative">
<img
src={imageUrl || "/people-placeholder.svg"}
alt={name}
className="h-24 w-24 rounded-full border-2 border-border/50 object-cover"
/>
</div>
<div className="text-center">
<p className="truncate font-medium text-foreground text-sm">{name}</p>
<p className="truncate text-muted-foreground text-xs">{role}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,174 @@
import type { Models } from "@nontara/server/models";
import { useQuery } from "@tanstack/react-query";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { http } from "@/lib/api";
import {
HorizontalScrollList,
type HorizontalScrollListRef,
} from "./layout/HorizontalScrollList";
import MovieCard from "./MovieCard";
const RecentlyAddedSections: React.FC = () => {
const scrollListRef = React.useRef<HorizontalScrollListRef>(null);
const [canScrollLeft, setCanScrollLeft] = React.useState(false);
const [canScrollRight, setCanScrollRight] = React.useState(false);
const handleScrollableChange = React.useCallback(
(left: boolean, right: boolean) => {
setCanScrollLeft(left);
setCanScrollRight(right);
},
[],
);
const {
data: recentlyAddedMovies,
isLoading,
error,
refetch,
} = useQuery({
queryKey: ["recentlyAddedMovies"],
queryFn: async () => {
const response = await http.get<Models.API.RecentlyAddedOutput>(
"/media/recentlyAdded",
{
params: {
limit: 10,
},
},
);
return response.data;
},
retry: 3,
retryDelay: (attemptIndex: number) =>
Math.min(1000 * 2 ** attemptIndex, 30000),
staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
});
// Loading skeleton
if (isLoading) {
return (
<div className="w-full rounded-lg p-6 shadow-sm">
<div className="pb-2 sm:pb-3">
<h3 className="font-semibold text-base sm:text-lg md:text-xl">
Recently Added
</h3>
</div>
<div className="px-2 sm:px-4">
<div className="flex space-x-2 overflow-hidden">
{Array.from({ length: 5 }).map((_, index) => (
<div
key={`skeleton-${Date.now()}-${index}`}
className="flex-none"
style={{ width: "150px" }}
>
<Skeleton className="aspect-[2/3] w-full rounded-xl" />
<Skeleton className="mt-2 h-4 w-full" />
<Skeleton className="mt-1 h-3 w-3/4" />
</div>
))}
</div>
</div>
</div>
);
}
// Error state with retry
if (error) {
return (
<div className="w-full rounded-lg p-6 shadow-sm">
<div className="pb-2 sm:pb-3">
<h3 className="font-semibold text-base sm:text-lg md:text-xl">
Recently Added
</h3>
</div>
<div>
<div className="flex flex-col items-center justify-center py-8 text-center">
<p className="mb-4 text-muted-foreground">
Failed to load recently added movies
</p>
<button
type="button"
onClick={() => refetch()}
className="rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/90"
>
Try Again
</button>
</div>
</div>
</div>
);
}
// Empty state
if (!recentlyAddedMovies || recentlyAddedMovies.length === 0) {
return (
<div className="w-full rounded-lg p-6 shadow-sm">
<div className="pb-2 sm:pb-3">
<h3 className="font-semibold text-base sm:text-lg md:text-xl">
Recently Added
</h3>
</div>
<div>
<div className="flex items-center justify-center py-8">
<p className="text-muted-foreground">No recently added movies</p>
</div>
</div>
</div>
);
}
return (
<div className="w-full rounded-lg p-6 shadow-sm">
<div className="pb-2 sm:pb-3">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-base sm:text-lg md:text-xl">
Recently Added
</h3>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => scrollListRef.current?.scrollLeft()}
aria-label="Scroll left"
disabled={!canScrollLeft}
>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => scrollListRef.current?.scrollRight()}
aria-label="Scroll right"
disabled={!canScrollRight}
>
</Button>
</div>
</div>
</div>
<div className="overflow-hidden">
<HorizontalScrollList
ref={scrollListRef}
className="w-full"
onScrollableChange={handleScrollableChange}
>
{recentlyAddedMovies.map((movie) => (
<div key={movie.id} className="w-36 flex-none md:w-48">
<MovieCard
id={movie.id}
imageUrl={movie.poster ?? undefined}
title={movie.title}
description={movie.year || ""}
/>
</div>
))}
</HorizontalScrollList>
</div>
</div>
);
};
export default RecentlyAddedSections;

View File

@@ -0,0 +1,44 @@
import { ChevronRight } from "lucide-react";
import MovieCard from "@/components/nontara/MovieCard";
import { Button } from "@/components/ui/button";
type SimilarTitle = {
id: string;
title: string;
year: number | null;
poster: string | null;
};
type SimilarTitlesSectionProps = {
titles: SimilarTitle[];
};
export default function SimilarTitlesSection({
titles,
}: SimilarTitlesSectionProps) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-lg">Similar Titles</h3>
<Button
variant="ghost"
size="sm"
className="text-primary hover:text-primary/80"
>
View All <ChevronRight className="ml-1 h-4 w-4" />
</Button>
</div>
<div className="no-scrollbar flex gap-4 overflow-x-auto pb-2">
{titles.map((similarTitle) => (
<MovieCard
key={similarTitle.id}
id={similarTitle.id}
imageUrl={similarTitle.poster || undefined}
title={similarTitle.title}
description={similarTitle.year ?? "Year unknown"}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,227 @@
import { Link, useNavigate } from "@tanstack/react-router";
import {
Bookmark,
Calendar,
Clock,
Film,
Play,
Share2,
Star,
} from "lucide-react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { formatRuntime } from "@/lib/utils";
import type { GetTitleDetailsOutput } from "../../../../server/src/models/trpc/titles";
import CastSection from "./CastSection";
import CrewSection from "./CrewSection";
import { EpisodesList } from "./EpisodeList";
import MediaTracksSelector from "./MediaTracksSelector";
import SimilarTitlesSection from "./SimilarTitlesSection";
type TitleDetailsProps = {
data: GetTitleDetailsOutput;
};
export default function TitleDetails({ data }: TitleDetailsProps) {
const navigate = useNavigate();
const [selectedTracks, setSelectedTracks] = useState<{
video?: number;
audio?: number;
subtitle?: number;
}>({});
const getEndTime = (runtimeSeconds: number) => {
const endTime = new Date(Date.now() + runtimeSeconds * 1000);
return endTime.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
});
};
const handlePlay = () => {
navigate({
to: "/watch/$id",
params: { id: data.id },
search: {
asi: selectedTracks.audio,
ssi: selectedTracks.subtitle,
},
});
};
return (
<div className="relative min-h-[calc(100vh-4rem)]">
{/* Backdrop with gradient overlay */}
<div className="absolute inset-0 h-[50vh] sm:h-[60vh] lg:h-[80vh]">
{data.backdrop && (
<img
src={data.backdrop}
alt={`${data.title} backdrop`}
className="h-full w-full object-cover"
/>
)}
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/80 to-background/20" />
<div className="absolute inset-0 bg-gradient-to-r from-background via-transparent to-background/60" />
</div>
{/* Content */}
<div className="relative z-10">
{/* Main Content */}
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-12">
<div className="grid grid-cols-1 items-start gap-6 sm:gap-8 lg:grid-cols-3 lg:gap-12">
{/* Poster */}
<div className="mx-auto w-full max-w-[200px] sm:max-w-xs md:max-w-sm lg:col-span-1 lg:max-w-none">
<Card className="overflow-hidden border-border/50 bg-card/50 p-0 backdrop-blur-sm">
<img
src={data.poster ?? "/movie-placeholder.svg"}
alt={`${data.title} poster`}
className="w-full object-contain"
/>
</Card>
</div>
{/* Movie Info */}
<div className="min-w-0 space-y-4 sm:space-y-6 lg:col-span-2">
{/* Title and Basic Info */}
<div className="space-y-2 sm:space-y-3">
<h1 className="text-balance break-words font-bold text-2xl leading-tight sm:text-3xl md:text-4xl lg:text-5xl xl:text-6xl">
{data.title}
</h1>
{data.originalTitle && data.originalTitle !== data.title && (
<div className="inline-block rounded-md bg-muted px-3 py-1 text-muted-foreground text-sm">
{data.originalTitle}
</div>
)}
<div className="flex flex-wrap items-center gap-2 text-muted-foreground text-xs sm:gap-3 sm:text-sm">
{data.year && (
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{data.year}
</span>
)}
{data.type === "MOVIE" && data.runtimeSeconds && (
<>
<span className="flex items-center gap-1">
<Clock className="h-4 w-4" />
{formatRuntime(data.runtimeSeconds)}
</span>
<span className="flex items-center gap-1">
Ends at {getEndTime(data.runtimeSeconds)}
</span>
</>
)}
{data.type === "SERIES" && (
<span className="flex items-center gap-1">
<Film className="h-4 w-4" />
{data.seasonsCount}{" "}
{data.seasonsCount === 1 ? "Season" : "Seasons"} {" "}
{data.episodesCount}{" "}
{data.episodesCount === 1 ? "Episode" : "Episodes"}
</span>
)}
{data.rating && (
<div className="flex items-center gap-1">
<Star className="h-4 w-4 fill-primary text-primary" />
<span className="font-medium text-foreground">
{data.rating.toFixed(1)}
</span>
</div>
)}
</div>
<div className="flex flex-wrap gap-1.5 sm:gap-2">
{data.genres.map((genre) => (
<Badge
key={genre.id}
variant="secondary"
className="bg-secondary/50 text-secondary-foreground text-xs sm:text-sm"
>
{genre.name}
</Badge>
))}
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:gap-3">
<Button
size="lg"
className="w-full bg-primary text-primary-foreground hover:bg-primary/90 sm:w-auto"
onClick={handlePlay}
asChild
>
<Link
to="/watch/$id"
params={{ id: data.id }}
search={{
asi: selectedTracks.audio,
ssi: selectedTracks.subtitle,
}}
>
<Play className="mr-2 h-5 w-5" />
Play
</Link>
</Button>
<Button
variant="outline"
size="lg"
className="w-full sm:w-auto"
>
<Bookmark className="mr-2 h-5 w-5" />
Watchlist
</Button>
<Button
variant="outline"
size="lg"
className="w-full sm:w-auto"
>
<Share2 className="mr-2 h-5 w-5" />
Share
</Button>
</div>
{/* Synopsis */}
<div className="space-y-2 sm:space-y-3">
<h2 className="font-semibold text-lg sm:text-xl">Synopsis</h2>
<p className="text-pretty text-muted-foreground text-sm leading-relaxed sm:text-base">
{data.synopsis ?? "No synopsis available."}
</p>
</div>
{/* Media Tracks - Only for Movies */}
{data.type === "MOVIE" && (
<MediaTracksSelector
titleId={data.id}
onTracksChange={setSelectedTracks}
/>
)}
{/* Episodes - Only for Series */}
{data.type === "SERIES" && data.seasons.length > 0 && (
<EpisodesList titleId={data.id} seasons={data.seasons} />
)}
{/* Cast */}
{data.cast.length > 0 && <CastSection cast={data.cast} />}
{/* Crew */}
{data.crew.length > 0 && <CrewSection crew={data.crew} />}
{/* Similar Titles */}
{data.similarTitles.length > 0 && (
<SimilarTitlesSection titles={data.similarTitles} />
)}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,92 @@
import * as React from "react";
import { cn } from "@/lib/utils";
interface HorizontalScrollListProps {
children: React.ReactNode;
className?: string;
scrollStep?: number;
onScrollableChange?: (
canScrollLeft: boolean,
canScrollRight: boolean,
) => void;
}
export interface HorizontalScrollListRef {
scrollLeft: () => void;
scrollRight: () => void;
}
export const HorizontalScrollList = React.forwardRef<
HorizontalScrollListRef,
HorizontalScrollListProps
>(({ children, className, scrollStep = 200, onScrollableChange }, ref) => {
const scrollRef = React.useRef<HTMLDivElement>(null);
const checkScrollable = React.useCallback(() => {
if (!scrollRef.current || !onScrollableChange) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
const canScrollLeft = scrollLeft > 0;
const canScrollRight = scrollLeft + clientWidth < scrollWidth;
onScrollableChange(canScrollLeft, canScrollRight);
}, [onScrollableChange]);
const scrollLeft = React.useCallback(() => {
if (!scrollRef.current) return;
scrollRef.current.scrollBy({ left: -scrollStep, behavior: "smooth" });
}, [scrollStep]);
const scrollRight = React.useCallback(() => {
if (!scrollRef.current) return;
scrollRef.current.scrollBy({ left: scrollStep, behavior: "smooth" });
}, [scrollStep]);
React.useEffect(() => {
checkScrollable();
}, [checkScrollable]);
React.useEffect(() => {
const element = scrollRef.current;
if (!element) return;
const handleScroll = () => {
checkScrollable();
};
const handleResize = () => {
checkScrollable();
};
element.addEventListener("scroll", handleScroll);
window.addEventListener("resize", handleResize);
return () => {
element.removeEventListener("scroll", handleScroll);
window.removeEventListener("resize", handleResize);
};
}, [checkScrollable]);
React.useImperativeHandle(
ref,
() => ({
scrollLeft,
scrollRight,
}),
[scrollLeft, scrollRight],
);
return (
<div className={cn("w-full", className)}>
<section
ref={scrollRef}
className="scrollbar-hide no-scrollbar flex gap-2 overflow-x-auto overflow-y-hidden scroll-smooth"
aria-label="Scrollable content"
>
{children}
</section>
</div>
);
});
HorizontalScrollList.displayName = "HorizontalScrollList";

View File

@@ -0,0 +1,97 @@
import {
MediaCaptionsButton,
MediaControlBar,
MediaController,
MediaFullscreenButton,
MediaMuteButton,
MediaPlayButton,
MediaSeekBackwardButton,
MediaSeekForwardButton,
MediaTimeDisplay,
MediaTimeRange,
MediaVolumeRange,
} from "media-chrome/react";
import { useMemo } from "react";
import ReactPlayer from "react-player";
import { PlayerErrorState } from "@/components/player/states/PlayerErrorState";
import { PlayerLoadingState } from "@/components/player/states/PlayerLoadingState";
import { detectCodecSupport } from "@/utils/codec-detection";
import { usePlaybackSession } from "./hooks/usePlaybackSession";
type SelectedTracks = {
audio?: number;
subtitle?: number;
};
interface HLSPlayerProps {
titleId: string;
episodeId?: string;
selectedTracks?: SelectedTracks;
}
export function HLSPlayer({
titleId,
episodeId,
selectedTracks,
}: HLSPlayerProps) {
const supportedCodecs = useMemo(() => detectCodecSupport(), []);
const { isLoading, isError, data, error } = usePlaybackSession({
titleId,
episodeId,
supportedCodecs,
selectedTracks,
});
if (isLoading) {
return <PlayerLoadingState />;
}
if (isError) {
return <PlayerErrorState error={new Error(error.message)} />;
}
if (!data) {
return <PlayerLoadingState />;
}
return (
<MediaController
style={{
width: "100%",
aspectRatio: "16/9",
}}
>
<ReactPlayer
slot="media"
src={data.playlistUrl}
config={{
hls: {
enableWorker: true,
startPosition: 0,
autoStartLoad: true,
},
}}
playing
playsInline
controls={false}
style={{
width: "100%",
height: "100%",
// "--controls": "none",
}}
/>
<MediaControlBar>
<MediaPlayButton />
<MediaSeekBackwardButton seekOffset={10} />
<MediaSeekForwardButton seekOffset={10} />
<MediaTimeRange />
<MediaTimeDisplay showDuration />
<MediaMuteButton />
<MediaVolumeRange />
<MediaCaptionsButton />
<MediaFullscreenButton />
</MediaControlBar>
</MediaController>
);
}

View File

@@ -0,0 +1,101 @@
import type { Models } from "@nontara/server/models";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useEffect, useEffectEvent, useRef } from "react";
import { toast } from "sonner";
import { http } from "@/lib/api";
import type { AudioCodec, VideoCodec } from "@/utils/codec-detection";
interface UsePlaybackSessionOptions {
titleId?: string;
episodeId?: string;
supportedCodecs: {
video: VideoCodec[];
audio: AudioCodec[];
};
selectedTracks?: {
audio?: number;
subtitle?: number;
};
maxResolution?: "480p" | "720p" | "1080p" | "2160p";
}
export function usePlaybackSession(options: UsePlaybackSessionOptions) {
const sessionIdRef = useRef<string | null>(
sessionStorage.getItem("activePlaybackSessionId"),
);
const { isError, data, error, isPending } = useQuery({
queryKey: ["playback-session", options.titleId, options.episodeId],
queryFn: async () => {
const response = await http.post<Models.API.PlaybackCreateOutput>(
"/playback/create",
{
titleId: options.titleId,
episodeId: options.episodeId,
supportedVideoCodecs: options.supportedCodecs.video,
supportedAudioCodecs: options.supportedCodecs.audio,
subtitleStreamIndex: options.selectedTracks?.subtitle,
maxResolution: options.maxResolution || "1080p",
burnInSubtitle: false,
},
);
sessionIdRef.current = response.data.sessionId;
sessionStorage.setItem(
"activePlaybackSessionId",
response.data.sessionId,
);
return response.data;
},
enabled: !sessionIdRef.current,
staleTime: Number.POSITIVE_INFINITY,
gcTime: Number.POSITIVE_INFINITY,
retry: 2,
});
const { mutate: mutateStopSession } = useMutation({
mutationFn: async (variables: Models.API.PlaybackStopInput) => {
const response = await http.delete<Models.API.PlaybackStopOutput>(
"playback/stop",
{
params: variables,
},
);
return response.data;
},
onSuccess: () => {
sessionStorage.removeItem("activePlaybackSessionId");
sessionIdRef.current = null;
},
});
const onDisposed = useEffectEvent(() => {
if (!sessionIdRef.current) return;
mutateStopSession({
sessionId: sessionIdRef.current,
});
});
useEffect(() => {
return () => {
onDisposed();
};
}, []);
if (isError) {
toast.error(`Failed to create playback session: ${error?.message}`);
return {
isLoading: isPending,
isError: isError,
error: error,
sessionIdRef,
};
}
return {
isLoading: isPending,
isError: isError,
error: error,
data: data,
sessionIdRef,
};
}

View File

@@ -0,0 +1,33 @@
import { useNavigate } from "@tanstack/react-router";
import { AlertCircle, ArrowLeft } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
interface PlayerErrorStateProps {
error: Error;
}
export function PlayerErrorState({ error }: PlayerErrorStateProps) {
const navigate = useNavigate();
return (
<div className="flex h-screen w-full items-center justify-center bg-black p-4">
<div className="w-full max-w-md space-y-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Playback Error</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
</Alert>
<Button
variant="outline"
className="w-full"
onClick={() => navigate({ to: "/home" })}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Home
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { Loader2 } from "lucide-react";
export function PlayerLoadingState() {
return (
<div className="flex h-screen w-full items-center justify-center bg-black">
<div className="text-center">
<Loader2 className="mx-auto h-12 w-12 animate-spin text-primary" />
<p className="mt-4 text-muted-foreground">Preparing playback...</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import type React from "react";
import { OnboardProviderContext } from "@/hooks/useOnboard";
import { OnboardSessionService } from "@/services/onboard";
type OnboardProviderProps = {
children: React.ReactNode;
};
export function OnboardProvider({ children, ...props }: OnboardProviderProps) {
return (
<OnboardProviderContext {...props} value={new OnboardSessionService()}>
{children}
</OnboardProviderContext>
);
}

View File

@@ -0,0 +1,51 @@
import { useEffect, useState } from "react";
import { type Theme, ThemeProviderContext } from "@/hooks/useTheme";
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
};
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vite-ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
);
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove("light", "dark");
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}, [theme]);
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
},
};
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
);
}

View File

@@ -0,0 +1,222 @@
import type { Models } from "@nontara/server/models";
import { useMutation } from "@tanstack/react-query";
import { ChevronRightIcon, FolderIcon, HomeIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { http } from "@/lib/api";
interface FolderExplorerProps {
onPathSelect: (path: string) => void;
initialPath?: string;
}
export function FolderExplorer({
onPathSelect,
initialPath,
}: FolderExplorerProps) {
const [currentFolderPath, setCurrentFolderPath] = useState<string[]>(() => {
if (initialPath?.trim()) {
return initialPath.split("/").filter(Boolean);
}
return [];
});
const [folderContents, setFolderContents] = useState<
Models.API.OnboardFolderOutput[]
>([]);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// const trpc = useTRPC();
const folderExplorerMutation = useMutation({
mutationFn: async (variables: Models.API.OnboardFolderInput) => {
const response = await http.post<Models.API.OnboardFolderOutput[]>(
"onboard/folder",
variables,
);
return response.data;
},
});
useEffect(() => {
// Clear any previous errors when path changes
setErrorMessage(null);
const timeoutId = setTimeout(() => {
folderExplorerMutation.mutate(
{ path: currentFolderPath },
{
onSuccess: (data) => {
setFolderContents(data);
setErrorMessage(null);
},
onError: (error) => {
// Clear folder contents on error
setFolderContents([]);
// Extract error message
const message = error.message || "Failed to load folders";
// Set error message for display
setErrorMessage(message);
// Show toast with appropriate message based on error type
if (
message.includes("Access denied") ||
message.includes("permission")
) {
toast.error("Access Denied", {
description:
"You don't have permission to access this directory.",
});
} else if (
message.includes("not found") ||
message.includes("does not exist")
) {
toast.error("Directory Not Found", {
description: "The directory no longer exists or was moved.",
});
// Go back to parent directory if current path doesn't exist
if (currentFolderPath.length > 0) {
setCurrentFolderPath(currentFolderPath.slice(0, -1));
}
} else if (message.includes("not a directory")) {
toast.error("Invalid Path", {
description: "The selected path is not a directory.",
});
// Go back to parent directory
if (currentFolderPath.length > 0) {
setCurrentFolderPath(currentFolderPath.slice(0, -1));
}
} else if (message.includes("too long")) {
toast.error("Path Too Long", {
description: "The path exceeds the maximum allowed length.",
});
} else if (message.includes("I/O error")) {
toast.error("Filesystem Error", {
description:
"Unable to read from the filesystem. Please try again.",
});
} else if (message.includes("symbolic links")) {
toast.error("Invalid Path", {
description:
"Unable to resolve path due to circular symbolic links.",
});
} else {
// Generic error
toast.error("Failed to Load Folders", {
description: message,
});
}
console.error("Folder loading error:", error);
},
},
);
}, 100);
return () => clearTimeout(timeoutId);
}, [currentFolderPath, folderExplorerMutation.mutate]);
const handleFolderClick = useCallback(
(folderName: string) => {
const newPath = [...currentFolderPath, folderName];
setCurrentFolderPath(newPath);
const pathString = newPath.join("/");
onPathSelect(pathString);
},
[currentFolderPath, onPathSelect],
);
const handlePathClick = useCallback(
(index: number) => {
const newPath = currentFolderPath.slice(0, index + 1);
setCurrentFolderPath(newPath);
const pathString = newPath.join("/");
onPathSelect(pathString);
},
[currentFolderPath, onPathSelect],
);
const handleHomeClick = useCallback(() => {
setCurrentFolderPath([]);
onPathSelect("");
}, [onPathSelect]);
return (
<div className="space-y-2">
<Label>Folder Explorer</Label>
<div className="space-y-3 rounded-md border p-3">
{/* Breadcrumb navigation */}
<div className="no-scrollbar flex items-center gap-1 overflow-x-auto text-sm">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleHomeClick}
className="h-6 shrink-0 px-2"
>
<HomeIcon className="size-3" />
</Button>
<div className="no-scrollbar flex min-w-0 max-w-3.5 items-center gap-1">
{currentFolderPath.map((folder, index) => (
<div
key={`${folder}-${currentFolderPath.slice(0, index + 1).join("/")}`}
className="flex shrink-0 items-center gap-1"
>
<ChevronRightIcon className="size-3 shrink-0 text-muted-foreground" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handlePathClick(index)}
className="h-6 max-w-32 shrink-0 px-2 text-xs"
title={folder}
>
<span className="block truncate">{folder}</span>
</Button>
</div>
))}
</div>
</div>
{/* Folder list */}
<div className="no-scrollbar max-h-40 space-y-1 overflow-y-auto">
{folderExplorerMutation.isPending ? (
<div className="flex items-center justify-center py-4 text-muted-foreground text-sm">
Loading folders...
</div>
) : errorMessage ? (
<div className="flex flex-col items-center justify-center gap-2 py-4 text-center">
<span className="font-medium text-destructive text-sm">
Unable to load folders
</span>
<span className="text-muted-foreground text-xs">
{errorMessage}
</span>
</div>
) : folderContents.length === 0 ? (
<div className="flex items-center justify-center py-4 text-muted-foreground text-sm">
No folders found
</div>
) : (
folderContents.map((folder) => (
<Button
key={folder.name}
type="button"
variant="ghost"
size="sm"
onClick={() => handleFolderClick(folder.name)}
className="h-8 w-full justify-start gap-2 px-2"
>
<FolderIcon className="size-4 shrink-0 text-muted-foreground" />
<span className="overflow-hidden truncate text-ellipsis whitespace-nowrap text-left">
{folder.name}
</span>
</Button>
))
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,272 @@
import type { Models } from "@nontara/server/models";
import { useForm } from "@tanstack/react-form";
import { Loader2, Plus, Save } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { FolderExplorer } from "./FolderExplorer";
const LIBRARY_TYPE_OPTIONS = [
{ label: "Movies", value: "MOVIE" as Models.LibraryData["type"] },
{ label: "Series", value: "SERIES" as Models.LibraryData["type"] },
] as const;
interface LibraryDialogProps {
mode: "NEW" | "EDITING";
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (library: Omit<Models.LibraryData, "id"> & { id?: string }) => void;
defaultLibraryType: Models.LibraryData["type"];
initialLibrary?: Models.LibraryData;
}
export function LibraryDialog({
mode,
open,
onOpenChange,
onSubmit,
defaultLibraryType,
initialLibrary,
}: LibraryDialogProps) {
const libraryPathRef = useRef<HTMLInputElement>(null);
const isEditing = useMemo(() => mode === "EDITING", [mode]);
const form = useForm({
defaultValues: useMemo(() => {
return {
type: initialLibrary?.type || defaultLibraryType,
name: initialLibrary?.name || "",
path: initialLibrary?.path || "",
};
}, [initialLibrary, defaultLibraryType]),
onSubmit: async ({ value }) => {
const libraryData = {
...(initialLibrary?.id && { id: initialLibrary.id }),
name: value.name,
path: value.path,
type: value.type,
};
onSubmit(libraryData);
handleDialogOpenChange(false);
},
validators: {
onSubmit: ({ value }) => {
const errors: Record<string, string> = {};
if (!value.name?.trim()) {
errors.name = "Display name is required";
}
if (!value.path?.trim()) {
errors.path = "Folder path is required";
}
return Object.keys(errors).length > 0 ? errors : undefined;
},
},
});
const handleDialogOpenChange = useCallback(
(newOpen: boolean) => {
onOpenChange(newOpen);
if (!newOpen) {
form.reset();
}
},
[onOpenChange, form.reset],
);
const handlePathSelect = useCallback(
(path: string) => {
form.setFieldValue("path", path);
if (libraryPathRef.current) {
libraryPathRef.current.value = path;
}
},
[form.setFieldValue],
);
// Reset form when dialog opens with new data
useEffect(() => {
if (open) {
if (initialLibrary) {
form.setFieldValue("type", initialLibrary.type);
form.setFieldValue("name", initialLibrary.name);
form.setFieldValue("path", initialLibrary.path);
if (libraryPathRef.current) {
libraryPathRef.current.value = initialLibrary.path;
}
} else {
form.reset();
if (libraryPathRef.current) {
libraryPathRef.current.value = "";
}
}
}
}, [open, initialLibrary, form.setFieldValue, form.reset]);
return (
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isEditing ? "Edit media library" : "Add a media library"}
</DialogTitle>
<DialogDescription>
{isEditing
? "Adjust the details below to keep this library in sync."
: "Point us to the folders you'd like Nontara to manage."}
</DialogDescription>
</DialogHeader>
<form
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
form.handleSubmit();
}}
className="space-y-6"
>
<form.Field name="type">
{(field) => (
<div className="space-y-2">
<Label htmlFor="content-type">Content Type</Label>
<Select
value={field.state.value}
onValueChange={(value: Models.LibraryData["type"]) =>
field.handleChange(value)
}
>
<SelectTrigger id="content-type" className="w-full">
<SelectValue placeholder="Select content type" />
</SelectTrigger>
<SelectContent>
{LIBRARY_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{field.state.meta.errors.map((error) => (
<p key={error} className="text-destructive text-sm">
{error}
</p>
))}
</div>
)}
</form.Field>
<form.Field name="name">
{(field) => (
<div className="space-y-2">
<Label htmlFor="display-name">Display Name</Label>
<Input
id="display-name"
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
onBlur={field.handleBlur}
placeholder="e.g. Family Movies"
aria-invalid={field.state.meta.errors.length > 0}
/>
{field.state.meta.errors.map((error) => (
<p key={error} className="text-destructive text-sm">
{error}
</p>
))}
</div>
)}
</form.Field>
<form.Field name="path">
{(field) => (
<div className="space-y-2">
<Label htmlFor="folder-path">Folder Path</Label>
<Input
ref={libraryPathRef}
id="folder-path"
value={field.state.value}
onChange={(event) => {
field.handleChange(event.target.value);
if (libraryPathRef.current) {
libraryPathRef.current.value = event.target.value;
}
}}
onBlur={field.handleBlur}
placeholder="/media/movies"
aria-invalid={field.state.meta.errors.length > 0}
/>
{field.state.meta.errors.map((error) => (
<p key={error} className="text-destructive text-sm">
{error}
</p>
))}
</div>
)}
</form.Field>
<FolderExplorer
onPathSelect={handlePathSelect}
initialPath={isEditing ? initialLibrary?.path : undefined}
/>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</DialogClose>
<form.Subscribe>
{(state) => {
const hasErrors = Object.keys(state.errors).length > 0;
const isEmpty =
!state.values.name?.trim() || !state.values.path?.trim();
const canSubmit = state.canSubmit && !hasErrors && !isEmpty;
return (
<Button
type="submit"
disabled={!canSubmit || state.isSubmitting}
variant={canSubmit ? "default" : "secondary"}
className={`min-w-[120px] transition-all duration-200 ${
!canSubmit ? "cursor-not-allowed opacity-60" : ""
}`}
>
{state.isSubmitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{isEditing ? "Saving..." : "Adding..."}
</>
) : isEditing ? (
<>
<Save className="h-4 w-4" />
Save Changes
</>
) : (
<>
<Plus className="h-4 w-4" />
Add Library
</>
)}
</Button>
);
}}
</form.Subscribe>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,43 @@
import { ArrowLeft } from "lucide-react";
import { memo, useCallback } from "react";
import logoSrc from "@/assets/logo.svg";
import { Button } from "@/components/ui/button";
const SetupHeader = memo(function SetupHeader() {
const handleBack = useCallback(() => {
if (typeof window === "undefined") {
return;
}
window.history.back();
}, []);
return (
<header className="flex items-center justify-start gap-3 bg-muted px-5 py-3 shadow-sm">
<Button
type="button"
variant="ghost"
size="lg"
onClick={handleBack}
className="px-3"
aria-label="Go back"
>
<ArrowLeft className="size-5" aria-hidden="true" />
</Button>
<img
src={logoSrc}
alt="Nontara logo"
className="h-9 w-auto select-none"
loading="eager"
fetchPriority="high"
decoding="async"
draggable={false}
/>
</header>
);
});
SetupHeader.displayName = "SetupHeader";
export default SetupHeader;

View File

@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

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,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,60 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-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 DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground 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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,255 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,104 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn(
"flex max-w-sm flex-col items-center gap-2 text-center",
className
)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn("text-lg font-medium tracking-tight", className)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}

Some files were not shown because too many files have changed in this diff Show More