389 lines
15 KiB
TypeScript
389 lines
15 KiB
TypeScript
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. 1–16 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>
|
||
);
|
||
}
|