feat(onboard): add folder explorer and refactor library dialog

Introduces a new folder explorer component on the web client, allowing users to visually browse and select directories for media libraries. This is powered by a new server-side endpoint that lists folder contents, handling OS-specific root directory listing (Windows drives, Linux root).

The library setup dialog has been refactored into a dedicated component, encapsulating its logic and integrating the new folder explorer.

BREAKING CHANGE: The 'Path' Zod schema in 'apps/server/src/models/index.ts' has been updated from 'z.string()' to 'z.string().array()'. This affects the 'getFolderContents' TRPC procedure, which now expects an array of path segments.
This commit is contained in:
2025-09-24 12:34:22 +07:00
parent d9c99e0f73
commit 083fa81a6e
8 changed files with 485 additions and 180 deletions

View File

@@ -54,6 +54,12 @@ export const OnboardConfigurationData = z.object({
export type OnboardConfigurationData = z.infer<typeof OnboardConfigurationData>;
export const Path = z.object({
path: z.string(),
path: z.string().array(),
});
export type Path = z.infer<typeof Path>
export type Path = z.infer<typeof Path>;
export const Directory = z.object({
type: z.enum(["FOLDER"]),
name: z.string(),
});
export type Directory = z.infer<typeof Directory>;

View File

@@ -1,4 +1,8 @@
import { OnboardConfigurationData, Path } from "@/models";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { Directory, OnboardConfigurationData, Path } from "@/models";
import { getDrivesWMIC } from "@/utils/disks";
import { onboardProcedure, publicProcedure, router } from "../lib/trpc";
import { OnboardService } from "../services/onboard";
@@ -14,7 +18,30 @@ export const onboardRouter = router({
getFolderContents: onboardProcedure
.input(Path)
.mutation(async ({ ctx, input }) => {}),
.output(Directory.array())
.mutation(async ({ input }) => {
if (input.path.length === 0) {
if (os.platform() === "linux") {
const drives = fs.readdirSync("/", { withFileTypes: true });
return drives
.filter((v) => v.isDirectory())
.map((v): Directory => ({ name: v.name, type: "FOLDER" }));
}
const drives = getDrivesWMIC();
return drives.map((value): Directory => {
return {
name: `${value}/`,
type: "FOLDER",
};
});
}
const dirs = fs.readdirSync(path.join(...input.path), {
withFileTypes: true,
});
return dirs
.filter((v) => v.isDirectory())
.map((value): Directory => ({ name: value.name, type: "FOLDER" }));
}),
completeOnboarding: onboardProcedure
.input(OnboardConfigurationData)

View File

@@ -0,0 +1,9 @@
import { execSync } from "node:child_process";
export function getDrivesWMIC() {
const result = execSync("wmic logicaldisk get name", { encoding: "utf8" });
return result
.split("\n")
.map((line) => line.trim())
.filter((line) => /^[A-Z]:$/.test(line));
}

View File

@@ -0,0 +1,140 @@
import { useMutation } from "@tanstack/react-query";
import { ChevronRightIcon, FolderIcon, HomeIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import type { Models } from "server";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { useTRPC } from "@/utils/trpc";
interface FolderExplorerProps {
onPathSelect: (path: string) => void;
initialPath?: string;
}
export function FolderExplorer({ onPathSelect, initialPath }: FolderExplorerProps) {
const [currentFolderPath, setCurrentFolderPath] = useState<string[]>(() => {
if (initialPath && initialPath.trim()) {
return initialPath.split('/').filter(Boolean);
}
return [];
});
const [folderContents, setFolderContents] = useState<Models.Directory[]>([]);
const trpc = useTRPC();
const folderExplorerMutation = useMutation(
trpc.onboard.getFolderContents.mutationOptions(),
);
useEffect(() => {
const timeoutId = setTimeout(() => {
folderExplorerMutation.mutate(
{ path: currentFolderPath },
{
onSuccess: (data) => {
setFolderContents(data);
},
onError: (error) => {
toast.error("Failed to load folders");
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 flex-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 flex-shrink-0 items-center gap-1"
>
<ChevronRightIcon className="size-3 flex-shrink-0 text-muted-foreground" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handlePathClick(index)}
className="h-6 max-w-32 flex-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>
) : 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 flex-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,250 @@
import { useForm } from "@tanstack/react-form";
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { Models } from "server";
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>
<FolderExplorer
onPathSelect={handlePathSelect}
initialPath={isEditing ? initialLibrary?.path : undefined}
/>
<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>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</DialogClose>
<form.Subscribe>
{(state) => (
<Button
type="submit"
disabled={!state.canSubmit || state.isSubmitting}
>
{state.isSubmitting
? "Saving..."
: isEditing
? "Save Changes"
: "OK"}
</Button>
)}
</form.Subscribe>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -17,6 +17,10 @@ body {
}
}
@utility no-scrollbar {
@apply [scrollbar-width:none] [scrollbar-height:none] [&::-webkit-scrollbar]:hidden;
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);

View File

@@ -6,7 +6,7 @@ import {
MoreVerticalIcon,
PlusIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useMemo, useState, useCallback } from "react";
import type { Models } from "server";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -20,12 +20,6 @@ import {
} from "@/components/ui/card";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
@@ -34,15 +28,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { LibraryDialog } from "@/components/setup/LibraryDialog";
export const Route = createFileRoute("/welcome/setup")({
component: SetupLibrariesPage,
@@ -52,11 +38,6 @@ const PAGE_TITLE = "Add media libraries";
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_OPTIONS = [
{ label: "Movies", value: "MOVIE" as Models.LibraryData["type"] },
{ label: "Series", value: "SERIES" as Models.LibraryData["type"] },
] as const;
const LIBRARY_TYPE_LABEL: Record<Models.LibraryData["type"], string> = {
MOVIE: "Movies",
SERIES: "Series",
@@ -78,13 +59,7 @@ function SetupLibrariesPage() {
onboardSession.getLibraries(),
);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingLibraryId, setEditingLibraryId] = useState<string | null>(null);
const [libraryType, setLibraryType] =
useState<Models.LibraryData["type"]>("MOVIE");
const [libraryName, setLibraryName] = useState("");
const [libraryPath, setLibraryPath] = useState("");
const [errors, setErrors] = useState<{ name?: string; path?: string }>({});
const isEditing = editingLibraryId !== null;
const [editingLibrary, setEditingLibrary] = useState<Models.LibraryData | null>(null);
const defaultLibraryType = useMemo(() => {
const existingTypes = libraries.map((lib) => lib.type);
@@ -93,13 +68,6 @@ function SetupLibrariesPage() {
return "MOVIE";
}, [libraries]);
const resetForm = () => {
setLibraryType(defaultLibraryType);
setLibraryName("");
setLibraryPath("");
setErrors({});
};
const librariesCountLabel = useMemo(() => {
const count = libraries.length;
if (count === 0) return "No libraries added yet";
@@ -107,79 +75,48 @@ function SetupLibrariesPage() {
return `${count} libraries added`;
}, [libraries.length]);
const handleDialogOpenChange = (open: boolean) => {
const handleDialogOpenChange = useCallback((open: boolean) => {
setDialogOpen(open);
if (!open) {
setEditingLibraryId(null);
resetForm();
setEditingLibrary(null);
}
};
}, []);
const handleEditLibrary = (library: Models.LibraryData) => {
setEditingLibraryId(library.id);
setLibraryType(library.type);
setLibraryName(library.name);
setLibraryPath(library.path);
setErrors({});
const handleEditLibrary = useCallback((library: Models.LibraryData) => {
setEditingLibrary(library);
setDialogOpen(true);
};
}, []);
const handleDeleteLibrary = (library: Models.LibraryData) => {
const handleDeleteLibrary = useCallback((library: Models.LibraryData) => {
const nextLibraries = libraries.filter((item) => item.id !== library.id);
setLibraries(nextLibraries);
onboardSession.updateLibraries(nextLibraries);
toast.success(`${library.name} library deleted`);
if (editingLibraryId === library.id) {
handleDialogOpenChange(false);
if (editingLibrary?.id === library.id) {
setDialogOpen(false);
setEditingLibrary(null);
}
};
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
event.stopPropagation();
const trimmedName = libraryName.trim();
const trimmedPath = libraryPath.trim();
const nextErrors: { name?: string; path?: string } = {};
if (!trimmedName) {
nextErrors.name = "Display name is required";
}
if (!trimmedPath) {
nextErrors.path = "Folder path is required";
}
if (Object.keys(nextErrors).length > 0) {
setErrors(nextErrors);
return;
}
setErrors({});
}, [libraries, onboardSession, editingLibrary?.id]);
const handleLibrarySubmit = useCallback((libraryData: Omit<Models.LibraryData, "id"> & { id?: string }) => {
let nextLibraries: Models.LibraryData[];
if (isEditing && editingLibraryId) {
if (libraryData.id) {
// Editing existing library
nextLibraries = libraries.map((item) =>
item.id === editingLibraryId
? {
...item,
name: trimmedName,
path: trimmedPath,
type: libraryType,
}
item.id === libraryData.id
? { ...item, ...libraryData, id: libraryData.id }
: item,
);
toast.success(`${trimmedName} library updated`);
toast.success(`${libraryData.name} library updated`);
} else {
// Adding new library
const library: Models.LibraryData = {
id: createId(),
name: trimmedName,
path: trimmedPath,
type: libraryType,
name: libraryData.name,
path: libraryData.path,
type: libraryData.type,
};
nextLibraries = [...libraries, library];
@@ -188,8 +125,7 @@ function SetupLibrariesPage() {
setLibraries(nextLibraries);
onboardSession.updateLibraries(nextLibraries);
handleDialogOpenChange(false);
};
}, [libraries, onboardSession]);
const handleCompleteClick = () => {
if (libraries.length === 0) {
@@ -228,88 +164,22 @@ function SetupLibrariesPage() {
className="gap-2"
size="lg"
onClick={() => {
setEditingLibraryId(null);
resetForm();
setEditingLibrary(null);
}}
>
<PlusIcon className="size-4" />
New Library
</Button>
</DialogTrigger>
<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&apos;d like Nontara to manage."}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="content-type">Content Type</Label>
<Select
value={libraryType}
onValueChange={(value: Models.LibraryData["type"]) =>
setLibraryType(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>
</div>
<div className="space-y-2">
<Label htmlFor="display-name">Display Name</Label>
<Input
id="display-name"
value={libraryName}
onChange={(event) => setLibraryName(event.target.value)}
placeholder="e.g. Family Movies"
aria-invalid={Boolean(errors.name)}
/>
{errors.name && (
<p className="text-destructive text-sm">{errors.name}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="folder-path">Folder Path</Label>
<Input
id="folder-path"
value={libraryPath}
onChange={(event) => setLibraryPath(event.target.value)}
placeholder="/media/movies"
aria-invalid={Boolean(errors.path)}
/>
{errors.path && (
<p className="text-destructive text-sm">{errors.path}</p>
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</DialogClose>
<Button type="submit">
{isEditing ? "Save Changes" : "OK"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<LibraryDialog
mode={editingLibrary ? "EDITING" : "NEW"}
open={dialogOpen}
onOpenChange={handleDialogOpenChange}
onSubmit={handleLibrarySubmit}
defaultLibraryType={defaultLibraryType}
initialLibrary={editingLibrary || undefined}
/>
</section>
<section className="grid gap-4 sm:grid-cols-2">

View File

@@ -14,7 +14,6 @@
"@hono/trpc-server": "^0.4.0",
"@libsql/client": "^0.15.9",
"@nontara/language-codes": "workspace:*",
"@nontora/tmdb-metadata": "workspace:*",
"@trpc/client": "^11.5.0",
"@trpc/server": "^11.5.0",
"better-auth": "^1.3.10",
@@ -86,16 +85,7 @@
"web-vitals": "^5.0.3",
},
},
"packages/language-codes": {
"name": "@nontara/language-codes",
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
"packages/tmdb-metadata": {
"extensions/tmdb-metadata": {
"name": "@nontora/tmdb-metadata",
"dependencies": {
"tmdb-ts": "^2.0.2",
@@ -108,6 +98,15 @@
"typescript": "^5",
},
},
"packages/language-codes": {
"name": "@nontara/language-codes",
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="],
@@ -376,7 +375,7 @@
"@nontara/language-codes": ["@nontara/language-codes@workspace:packages/language-codes"],
"@nontora/tmdb-metadata": ["@nontora/tmdb-metadata@workspace:packages/tmdb-metadata"],
"@nontora/tmdb-metadata": ["@nontora/tmdb-metadata@workspace:extensions/tmdb-metadata"],
"@oozcitak/dom": ["@oozcitak/dom@1.15.10", "", { "dependencies": { "@oozcitak/infra": "1.0.8", "@oozcitak/url": "1.0.4", "@oozcitak/util": "8.3.8" } }, "sha512-0JT29/LaxVgRcGKvHmSrUTEvZ8BXvZhGl2LASRUgHqDTC1M5g1pLmVv56IYNyt3bG2CUjDkc67wnyZC14pbQrQ=="],