Files
spotizerr-dev/spotizerr-ui/src/components/config/ServerTab.tsx
2025-08-30 10:35:56 -06:00

389 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { useForm, Controller } from "react-hook-form";
import { authApiClient } from "../../lib/api-client";
import { toast } from "sonner";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
// --- Type Definitions ---
interface SpotifyApiSettings {
client_id: string;
client_secret: string;
}
interface WebhookSettings {
url: string;
events: string[];
available_events: string[]; // Provided by API, not saved
}
interface ServerConfig {
client_id?: string;
client_secret?: string;
utilityConcurrency?: number;
librespotConcurrency?: number;
url?: string;
events?: string[];
}
const fetchServerConfig = async (): Promise<ServerConfig> => {
const [spotifyConfig, generalConfig] = await Promise.all([
authApiClient.client.get("/credentials/spotify_api_config").catch(() => ({ data: {} })),
authApiClient.getConfig<any>(),
]);
return {
...spotifyConfig.data,
...generalConfig,
};
};
const saveServerConfig = async (data: Partial<ServerConfig>) => {
const payload = { ...data };
const { data: response } = await authApiClient.client.post("/config", payload);
return response;
};
const fetchWebhookConfig = async (): Promise<WebhookSettings> => {
// Mock a response since backend endpoint doesn't exist
// This will prevent the UI from crashing.
return Promise.resolve({
url: "",
events: [],
available_events: ["download_start", "download_complete", "download_failed", "watch_added"],
});
};
const saveWebhookConfig = async (data: Partial<WebhookSettings>) => {
const payload = { ...data };
const { data: response } = await authApiClient.client.post("/config", payload);
return response;
};
const testWebhook = (url: string) => {
toast.info("Webhook testing is not available.");
return Promise.resolve(url);
};
// --- Components ---
function SpotifyApiForm({ config, onConfigChange }: { config: ServerConfig; onConfigChange: (updates: Partial<ServerConfig>) => void }) {
const { register, handleSubmit, reset } = useForm<SpotifyApiSettings>();
useEffect(() => {
if (config) {
reset({
client_id: config.client_id || "",
client_secret: config.client_secret || "",
});
}
}, [config, reset]);
const onSubmit = (formData: SpotifyApiSettings) => {
onConfigChange(formData);
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="flex items-center justify-end mb-2">
<div className="flex items-center gap-3">
<button
type="submit"
className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md"
title="Save Spotify API Settings"
>
<img src="/save.svg" alt="Save" className="w-5 h-5 logo" />
</button>
</div>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="client_id" className="text-content-primary dark:text-content-primary-dark">Client ID</label>
<input
id="client_id"
type="password"
{...register("client_id")}
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"
placeholder="Optional"
/>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="client_secret" className="text-content-primary dark:text-content-primary-dark">Client Secret</label>
<input
id="client_secret"
type="password"
{...register("client_secret")}
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"
placeholder="Optional"
/>
</div>
</form>
);
}
function UtilityConcurrencyForm({ config, onConfigChange }: { config: ServerConfig; onConfigChange: (updates: Partial<ServerConfig>) => void }) {
const { register, handleSubmit, reset, formState: { isDirty } } = useForm<{ utilityConcurrency: number }>();
useEffect(() => {
if (config) {
reset({ utilityConcurrency: Number(config.utilityConcurrency ?? 1) });
}
}, [config, reset]);
const onSubmit = (values: { utilityConcurrency: number }) => {
const value = Math.max(1, Number(values.utilityConcurrency || 1));
onConfigChange({ utilityConcurrency: value });
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="flex items-center justify-end mb-2">
<div className="flex items-center gap-3">
<button
type="submit"
disabled={!isDirty}
className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50"
title="Save Utility Concurrency"
>
<img src="/save.svg" alt="Save" className="w-5 h-5 logo" />
</button>
</div>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="utilityConcurrency" className="text-content-primary dark:text-content-primary-dark">Utility Worker Concurrency</label>
<input
id="utilityConcurrency"
type="number"
min={1}
step={1}
{...register("utilityConcurrency", { valueAsNumber: true })}
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"
placeholder="1"
/>
<p className="text-xs text-content-secondary dark:text-content-secondary-dark">Controls concurrency of the utility Celery worker. Minimum 1.</p>
</div>
</form>
);
}
function LibrespotConcurrencyForm({ config, onConfigChange }: { config: ServerConfig; onConfigChange: (updates: Partial<ServerConfig>) => void }) {
const { register, handleSubmit, reset, formState: { isDirty } } = useForm<{ librespotConcurrency: number }>();
useEffect(() => {
if (config) {
reset({ librespotConcurrency: Number(config.librespotConcurrency ?? 2) });
}
}, [config, reset]);
const onSubmit = (values: { librespotConcurrency: number }) => {
const raw = Number(values.librespotConcurrency || 2);
const safe = Math.max(1, Math.min(16, raw));
onConfigChange({ librespotConcurrency: safe });
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="flex items-center justify-end mb-2">
<div className="flex items-center gap-3">
<button
type="submit"
disabled={!isDirty}
className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50"
title="Save Librespot Concurrency"
>
<img src="/save.svg" alt="Save" className="w-5 h-5 logo" />
</button>
</div>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="librespotConcurrency" className="text-content-primary dark:text-content-primary-dark">Librespot Concurrency</label>
<input
id="librespotConcurrency"
type="number"
min={1}
max={16}
step={1}
{...register("librespotConcurrency", { valueAsNumber: true })}
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"
placeholder="2"
/>
<p className="text-xs text-content-secondary dark:text-content-secondary-dark">Controls worker threads used by the Librespot client. 116 is recommended.</p>
</div>
</form>
);
}
// --- Components ---
function WebhookForm() {
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ["webhookConfig"], queryFn: fetchWebhookConfig });
const { register, handleSubmit, control, reset, watch } = useForm<WebhookSettings>();
const currentUrl = watch("url");
const mutation = useMutation({
mutationFn: saveWebhookConfig,
onSuccess: () => {
// No toast needed since the function shows one
queryClient.invalidateQueries({ queryKey: ["webhookConfig"] });
},
onError: (e) => {
toast.error(`Failed to save: ${(e as any).message}`);
},
});
const testMutation = useMutation({
mutationFn: testWebhook,
onSuccess: () => {
// No toast needed
},
onError: (e) => toast.error(`Webhook test failed: ${(e as any).message}`),
});
useEffect(() => {
if (data) reset(data);
}, [data, reset]);
const onSubmit = (formData: WebhookSettings) => mutation.mutate(formData);
if (isLoading) return <p className="text-content-muted dark:text-content-muted-dark">Loading Webhook settings...</p>;
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="flex items-center justify-end mb-2">
<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 Webhook 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="flex flex-col gap-2">
<label htmlFor="webhookUrl" className="text-content-primary dark:text-content-primary-dark">Webhook URL</label>
<input
id="webhookUrl"
type="url"
{...register("url")}
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"
placeholder="https://example.com/webhook"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-content-primary dark:text-content-primary-dark">Webhook Events</label>
<div className="grid grid-cols-2 gap-4 pt-2">
{data?.available_events.map((event) => (
<Controller
key={event}
name="events"
control={control}
render={({ field }) => (
<label className="flex items-center gap-2 text-content-primary dark:text-content-primary-dark">
<input
type="checkbox"
className="h-5 w-5 rounded"
checked={field.value?.includes(event) ?? false}
onChange={(e) => {
const value = field.value || [];
const newValues = e.target.checked ? [...value, event] : value.filter((v) => v !== event);
field.onChange(newValues);
}}
/>
<span className="capitalize">{event.replace(/_/g, " ")}</span>
</label>
)}
/>
))}
</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => testMutation.mutate(currentUrl)}
disabled={!currentUrl || testMutation.isPending}
className="px-4 py-2 bg-button-secondary hover:bg-button-secondary-hover text-button-secondary-text hover:text-button-secondary-text-hover rounded-md disabled:opacity-50"
>
Test
</button>
</div>
</form>
);
}
export function ServerTab() {
const queryClient = useQueryClient();
const [localConfig, setLocalConfig] = useState<ServerConfig>({});
const { data: serverConfig, isLoading } = useQuery({
queryKey: ["serverConfig"],
queryFn: fetchServerConfig,
});
const mutation = useMutation({
mutationFn: saveServerConfig,
onSuccess: () => {
toast.success("Server settings saved successfully!");
queryClient.invalidateQueries({ queryKey: ["serverConfig"] });
queryClient.invalidateQueries({ queryKey: ["config"] });
},
onError: (error) => {
console.error("Failed to save server settings", (error as any).message);
toast.error(`Failed to save server settings: ${(error as any).message}`);
},
});
useEffect(() => {
if (serverConfig) {
setLocalConfig(serverConfig);
}
}, [serverConfig]);
const handleConfigChange = (updates: Partial<ServerConfig>) => {
const newConfig = { ...localConfig, ...updates };
setLocalConfig(newConfig);
mutation.mutate(newConfig);
};
if (isLoading) {
return <div>Loading server settings...</div>;
}
return (
<div className="space-y-8">
<div>
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Spotify API</h3>
<p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">Provide your own API credentials to avoid rate-limiting issues.</p>
<SpotifyApiForm config={localConfig} onConfigChange={handleConfigChange} />
</div>
<hr className="border-border dark:border-border-dark" />
<div>
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Utility Worker</h3>
<p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">Tune background utility worker concurrency for low-powered systems.</p>
<UtilityConcurrencyForm config={localConfig} onConfigChange={handleConfigChange} />
</div>
<hr className="border-border dark:border-border-dark" />
<div>
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Librespot</h3>
<p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">Adjust Librespot client worker threads.</p>
<LibrespotConcurrencyForm config={localConfig} onConfigChange={handleConfigChange} />
</div>
<hr className="border-border dark:border-border-dark" />
<div>
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Webhooks</h3>
<p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">
Get notifications for events like download completion. (Currently disabled)
</p>
<WebhookForm />
</div>
</div>
);
}