feat: admin provider UI (types, hooks, guard, settings panel, conditional admin nav)
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useProviderConfigs, useUpdateProvider, useTestProvider } from "@/hooks/use-admin-providers";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { CheckCircle, XCircle, Loader2 } from "lucide-react";
|
||||
import { ApiRequestError } from "@/lib/api";
|
||||
|
||||
const PROVIDER_FIELDS: Record<
|
||||
string,
|
||||
Array<{ key: string; label: string; type?: string; required?: boolean }>
|
||||
> = {
|
||||
jellyfin: [
|
||||
{ key: "base_url", label: "Base URL", required: true },
|
||||
{ key: "api_key", label: "API Key", type: "password", required: true },
|
||||
{ key: "user_id", label: "User ID", required: true },
|
||||
],
|
||||
local_files: [
|
||||
{ key: "files_dir", label: "Files Directory", required: true },
|
||||
{ key: "transcode_dir", label: "Transcode Directory" },
|
||||
{ key: "cleanup_ttl_hours", label: "Cleanup TTL Hours" },
|
||||
],
|
||||
};
|
||||
|
||||
interface ProviderCardProps {
|
||||
providerType: string;
|
||||
existingConfig?: { config_json: Record<string, string>; enabled: boolean };
|
||||
}
|
||||
|
||||
function ProviderCard({ providerType, existingConfig }: ProviderCardProps) {
|
||||
const fields = PROVIDER_FIELDS[providerType] ?? [];
|
||||
const [formValues, setFormValues] = useState<Record<string, string>>(
|
||||
() => existingConfig?.config_json ?? {},
|
||||
);
|
||||
const [enabled, setEnabled] = useState(existingConfig?.enabled ?? true);
|
||||
const [conflictError, setConflictError] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
|
||||
const updateProvider = useUpdateProvider();
|
||||
const testProvider = useTestProvider();
|
||||
|
||||
const handleSave = async () => {
|
||||
setConflictError(false);
|
||||
try {
|
||||
await updateProvider.mutateAsync({
|
||||
type: providerType,
|
||||
payload: { config_json: formValues, enabled },
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof ApiRequestError && e.status === 409) {
|
||||
setConflictError(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
setTestResult(null);
|
||||
const result = await testProvider.mutateAsync({
|
||||
type: providerType,
|
||||
payload: { config_json: formValues, enabled: true },
|
||||
});
|
||||
setTestResult(result);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border-zinc-800 bg-zinc-900">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
||||
<CardTitle className="text-sm font-medium capitalize text-zinc-100">
|
||||
{providerType.replace("_", " ")}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-zinc-400">Enabled</span>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{conflictError && (
|
||||
<div className="rounded border border-yellow-600/40 bg-yellow-950/30 px-3 py-2 text-xs text-yellow-400">
|
||||
UI config disabled — set <code>CONFIG_SOURCE=db</code> on the server
|
||||
</div>
|
||||
)}
|
||||
{fields.map((field) => (
|
||||
<div key={field.key} className="space-y-1">
|
||||
<Label className="text-xs text-zinc-400">
|
||||
{field.label}
|
||||
{field.required && <span className="ml-1 text-red-400">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
type={field.type ?? "text"}
|
||||
value={formValues[field.key] ?? ""}
|
||||
onChange={(e) =>
|
||||
setFormValues((prev) => ({ ...prev, [field.key]: e.target.value }))
|
||||
}
|
||||
placeholder={
|
||||
field.type === "password" ? "••••••••" : `Enter ${field.label.toLowerCase()}`
|
||||
}
|
||||
className="h-8 border-zinc-700 bg-zinc-800 text-xs text-zinc-100"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{testResult && (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded px-3 py-2 text-xs ${
|
||||
testResult.ok
|
||||
? "bg-green-950/30 text-green-400"
|
||||
: "bg-red-950/30 text-red-400"
|
||||
}`}
|
||||
>
|
||||
{testResult.ok ? (
|
||||
<CheckCircle className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleTest}
|
||||
disabled={testProvider.isPending}
|
||||
className="border-zinc-700 text-xs"
|
||||
>
|
||||
{testProvider.isPending && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
|
||||
Test Connection
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={updateProvider.isPending}
|
||||
className="text-xs"
|
||||
>
|
||||
{updateProvider.isPending && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProviderSettingsPanel() {
|
||||
const { data: config } = useConfig();
|
||||
const { data: providerConfigs = [] } = useProviderConfigs();
|
||||
|
||||
const availableTypes = config?.available_provider_types ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-6">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-zinc-100">Provider Configuration</h2>
|
||||
<p className="mt-0.5 text-xs text-zinc-500">
|
||||
Configure media providers. Requires <code>CONFIG_SOURCE=db</code> on the server.
|
||||
</p>
|
||||
</div>
|
||||
{availableTypes.length === 0 ? (
|
||||
<p className="text-xs text-zinc-500">No providers available in this build.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{availableTypes.map((type) => {
|
||||
const existing = providerConfigs.find((c) => c.provider_type === type);
|
||||
return (
|
||||
<ProviderCard
|
||||
key={type}
|
||||
providerType={type}
|
||||
existingConfig={existing}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user