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:
@@ -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>;
|
||||
@@ -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)
|
||||
|
||||
9
apps/server/src/utils/disks.ts
Normal file
9
apps/server/src/utils/disks.ts
Normal 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));
|
||||
}
|
||||
140
apps/web/src/components/setup/FolderExplorer.tsx
Normal file
140
apps/web/src/components/setup/FolderExplorer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
250
apps/web/src/components/setup/LibraryDialog.tsx
Normal file
250
apps/web/src/components/setup/LibraryDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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'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">
|
||||
|
||||
23
bun.lock
23
bun.lock
@@ -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=="],
|
||||
|
||||
|
||||
Reference in New Issue
Block a user