mirror of
https://lavaforge.org/spotizerr/spotizerr.git
synced 2025-12-23 18:29:13 -05:00
234 lines
9.8 KiB
TypeScript
234 lines
9.8 KiB
TypeScript
import { useRef } from "react";
|
|
import { useForm, type SubmitHandler } from "react-hook-form";
|
|
import { authApiClient } from "../../lib/api-client";
|
|
import { toast } from "sonner";
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
|
|
// --- Type Definitions ---
|
|
interface FormattingSettings {
|
|
customDirFormat: string;
|
|
customTrackFormat: string;
|
|
tracknumPadding: boolean;
|
|
saveCover: boolean;
|
|
track: string;
|
|
album: string;
|
|
playlist: string;
|
|
compilation: string;
|
|
artistSeparator: string;
|
|
spotifyMetadata: boolean;
|
|
padNumberWidth?: number | "auto";
|
|
}
|
|
|
|
interface FormattingTabProps {
|
|
config: FormattingSettings;
|
|
isLoading: boolean;
|
|
}
|
|
|
|
// --- API Functions ---
|
|
const saveFormattingConfig = async (data: Partial<FormattingSettings>) => {
|
|
const payload: any = { ...data };
|
|
const { data: response } = await authApiClient.client.post("/config", payload);
|
|
return response;
|
|
};
|
|
|
|
// --- Placeholders ---
|
|
const placeholders = {
|
|
Common: {
|
|
"%music%": "Track title",
|
|
"%artist%": "Track artist (use %arist_1%, %artist_2%, etc. for selecting specific artists)",
|
|
"%album%": "Album name",
|
|
"%ar_album%": "Album artist (use %ar_album_1%, %ar_album_2%, etc. for selecting specific album artists)",
|
|
"%tracknum%": "Track number",
|
|
"%year%": "Year of release",
|
|
},
|
|
Additional: {
|
|
"%discnum%": "Disc number",
|
|
"%date%": "Release date",
|
|
"%genre%": "Music genre",
|
|
"%isrc%": "ISRC",
|
|
"%explicit%": "Explicit flag",
|
|
"%duration%": "Track duration (s)",
|
|
"%playlist%": "Playlist name",
|
|
"%playlistnum%": "Track number within its playlist",
|
|
},
|
|
Indexed: {
|
|
"%ar_album_1%": "Album artist #1 (use _2, _3, ...)",
|
|
"%artist_1%": "Track artist #1 (use _2, _3, ...)",
|
|
"%ar_album_2%": "Album artist #2",
|
|
"%artist_2%": "Track artist #2",
|
|
},
|
|
};
|
|
|
|
const PlaceholderSelector = ({ onSelect }: { onSelect: (value: string) => void }) => (
|
|
<select
|
|
onChange={(e) => onSelect(e.target.value)}
|
|
className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus text-sm mt-1"
|
|
>
|
|
<option value="">-- Insert Placeholder --</option>
|
|
{Object.entries(placeholders).map(([group, options]) => (
|
|
<optgroup label={group} key={group}>
|
|
{Object.entries(options).map(([value, label]) => (
|
|
<option key={value} value={value}>{`${value} - ${label}`}</option>
|
|
))}
|
|
</optgroup>
|
|
))}
|
|
</select>
|
|
);
|
|
|
|
// --- Component ---
|
|
export function FormattingTab({ config, isLoading }: FormattingTabProps) {
|
|
const queryClient = useQueryClient();
|
|
const dirInputRef = useRef<HTMLInputElement | null>(null);
|
|
const trackInputRef = useRef<HTMLInputElement | null>(null);
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: saveFormattingConfig,
|
|
onSuccess: () => {
|
|
toast.success("Formatting settings saved!");
|
|
queryClient.invalidateQueries({ queryKey: ["config"] });
|
|
},
|
|
onError: (error) => {
|
|
console.error("Failed to save formatting settings:", (error as any).message);
|
|
toast.error(`Failed to save settings: ${(error as any).message}`);
|
|
},
|
|
});
|
|
|
|
const { register, handleSubmit, setValue } = useForm<FormattingSettings>({
|
|
values: {
|
|
...config,
|
|
padNumberWidth: config.padNumberWidth ?? 3,
|
|
},
|
|
});
|
|
|
|
// Correctly register the refs for react-hook-form while also holding a local ref.
|
|
const { ref: dirFormatRef, ...dirFormatRest } = register("customDirFormat");
|
|
const { ref: trackFormatRef, ...trackFormatRest } = register("customTrackFormat");
|
|
|
|
const handlePlaceholderSelect =
|
|
(field: "customDirFormat" | "customTrackFormat", inputRef: React.RefObject<HTMLInputElement | null>) =>
|
|
(value: string) => {
|
|
if (!value || !inputRef.current) return;
|
|
const { selectionStart, selectionEnd } = inputRef.current;
|
|
const currentValue = inputRef.current.value;
|
|
const newValue =
|
|
currentValue.substring(0, selectionStart ?? 0) + value + currentValue.substring(selectionEnd ?? 0);
|
|
setValue(field, newValue);
|
|
};
|
|
|
|
const onSubmit: SubmitHandler<FormattingSettings> = (data) => {
|
|
mutation.mutate(data);
|
|
};
|
|
|
|
if (isLoading) {
|
|
return <div className="text-content-muted dark:text-content-muted-dark">Loading formatting settings...</div>;
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
|
|
<div className="flex items-center justify-end mb-4">
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
type="submit"
|
|
disabled={mutation.isPending}
|
|
className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50"
|
|
title="Save Formatting Settings"
|
|
>
|
|
{mutation.isPending ? (
|
|
<img src="/spinner.svg" alt="Saving" className="w-5 h-5 animate-spin logo" />
|
|
) : (
|
|
<img src="/save.svg" alt="Save" className="w-5 h-5 logo" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">File Naming</h3>
|
|
<div className="flex flex-col gap-2">
|
|
<label htmlFor="customDirFormat" className="text-content-primary dark:text-content-primary-dark">Custom Directory Format</label>
|
|
<input
|
|
id="customDirFormat"
|
|
type="text"
|
|
{...dirFormatRest}
|
|
ref={(e) => {
|
|
dirFormatRef(e);
|
|
dirInputRef.current = e;
|
|
}}
|
|
className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
|
/>
|
|
<PlaceholderSelector onSelect={handlePlaceholderSelect("customDirFormat", dirInputRef)} />
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
<label htmlFor="customTrackFormat" className="text-content-primary dark:text-content-primary-dark">Custom Track Format</label>
|
|
<input
|
|
id="customTrackFormat"
|
|
type="text"
|
|
{...trackFormatRest}
|
|
ref={(e) => {
|
|
trackFormatRef(e);
|
|
trackInputRef.current = e;
|
|
}}
|
|
className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
|
/>
|
|
<PlaceholderSelector onSelect={handlePlaceholderSelect("customTrackFormat", trackInputRef)} />
|
|
</div>
|
|
<div className="text-sm text-content-muted dark:text-content-muted-dark">
|
|
Tip: You can select specific artists using indexed placeholders like <code>%ar_album_1%</code> or
|
|
<code> %artist_1%</code>. Append <code>_2</code>, <code>_3</code>, etc. to target later artists. If the index
|
|
exceeds available artists (e.g. <code>%artist_3%</code> but a track only has two artists), the first artist is
|
|
used as a fallback.
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<label htmlFor="tracknumPaddingToggle" className="text-content-primary dark:text-content-primary-dark">Track Number Padding</label>
|
|
<input
|
|
id="tracknumPaddingToggle"
|
|
type="checkbox"
|
|
{...register("tracknumPadding")}
|
|
className="h-6 w-6 rounded"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-4">
|
|
<label htmlFor="padNumberWidth" className="text-content-primary dark:text-content-primary-dark">Track Number Padding Width</label>
|
|
<input
|
|
id="padNumberWidth"
|
|
type="text"
|
|
placeholder="3 or auto"
|
|
{...register("padNumberWidth", {
|
|
setValueAs: (v) => {
|
|
if (typeof v !== "string") return v;
|
|
const trimmed = v.trim().toLowerCase();
|
|
if (trimmed === "auto") return "auto" as const;
|
|
const parsed = parseInt(trimmed, 10);
|
|
return Number.isNaN(parsed) ? 3 : parsed;
|
|
},
|
|
})}
|
|
className="block w-40 p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus text-sm"
|
|
/>
|
|
</div>
|
|
<p className="text-xs text-content-muted dark:text-content-muted-dark">
|
|
"01. Track" if set to 2, "001. Track" if set to 3...
|
|
</p>
|
|
<div className="flex items-center justify-between">
|
|
<label htmlFor="artistSeparator" className="text-content-primary dark:text-content-primary-dark">Artist Separator</label>
|
|
<input
|
|
id="artistSeparator"
|
|
type="text"
|
|
maxLength={8}
|
|
placeholder="; "
|
|
{...register("artistSeparator")}
|
|
className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<label htmlFor="saveCoverToggle" className="text-content-primary dark:text-content-primary-dark">Save Album Cover</label>
|
|
<input id="saveCoverToggle" type="checkbox" {...register("saveCover")} className="h-6 w-6 rounded" />
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<label htmlFor="spotifyMetadataToggle" className="text-content-primary dark:text-content-primary-dark">Use Spotify metadata in Deezer fallback</label>
|
|
<input id="spotifyMetadataToggle" type="checkbox" {...register("spotifyMetadata")} className="h-6 w-6 rounded" />
|
|
</div>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|