From 89036ba62d21966494d9d597fa07143e78d6d70c Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 16 Mar 2026 03:38:37 +0100 Subject: [PATCH] feat: admin provider UI (types, hooks, guard, settings panel, conditional admin nav) --- .../components/provider-settings-panel.tsx | 180 ++++++++++++++++++ k-tv-frontend/app/(main)/admin/layout.tsx | 27 +++ k-tv-frontend/app/(main)/admin/page.tsx | 67 ++++--- .../app/(main)/components/admin-nav-link.tsx | 17 ++ k-tv-frontend/app/(main)/layout.tsx | 5 +- k-tv-frontend/hooks/use-admin-providers.ts | 52 +++++ k-tv-frontend/lib/api.ts | 32 ++++ k-tv-frontend/lib/types.ts | 13 ++ 8 files changed, 367 insertions(+), 26 deletions(-) create mode 100644 k-tv-frontend/app/(main)/admin/components/provider-settings-panel.tsx create mode 100644 k-tv-frontend/app/(main)/admin/layout.tsx create mode 100644 k-tv-frontend/app/(main)/components/admin-nav-link.tsx create mode 100644 k-tv-frontend/hooks/use-admin-providers.ts diff --git a/k-tv-frontend/app/(main)/admin/components/provider-settings-panel.tsx b/k-tv-frontend/app/(main)/admin/components/provider-settings-panel.tsx new file mode 100644 index 0000000..a05bf95 --- /dev/null +++ b/k-tv-frontend/app/(main)/admin/components/provider-settings-panel.tsx @@ -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; enabled: boolean }; +} + +function ProviderCard({ providerType, existingConfig }: ProviderCardProps) { + const fields = PROVIDER_FIELDS[providerType] ?? []; + const [formValues, setFormValues] = useState>( + () => 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 ( + + + + {providerType.replace("_", " ")} + +
+ Enabled + +
+
+ + {conflictError && ( +
+ UI config disabled — set CONFIG_SOURCE=db on the server +
+ )} + {fields.map((field) => ( +
+ + + 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" + /> +
+ ))} + {testResult && ( +
+ {testResult.ok ? ( + + ) : ( + + )} + {testResult.message} +
+ )} +
+ + +
+
+
+ ); +} + +export function ProviderSettingsPanel() { + const { data: config } = useConfig(); + const { data: providerConfigs = [] } = useProviderConfigs(); + + const availableTypes = config?.available_provider_types ?? []; + + return ( +
+
+

Provider Configuration

+

+ Configure media providers. Requires CONFIG_SOURCE=db on the server. +

+
+ {availableTypes.length === 0 ? ( +

No providers available in this build.

+ ) : ( +
+ {availableTypes.map((type) => { + const existing = providerConfigs.find((c) => c.provider_type === type); + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/k-tv-frontend/app/(main)/admin/layout.tsx b/k-tv-frontend/app/(main)/admin/layout.tsx new file mode 100644 index 0000000..aa4b849 --- /dev/null +++ b/k-tv-frontend/app/(main)/admin/layout.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useEffect, type ReactNode } from "react"; +import { useRouter } from "next/navigation"; +import { useCurrentUser } from "@/hooks/use-auth"; +import { useAuthContext } from "@/context/auth-context"; + +export default function AdminLayout({ children }: { children: ReactNode }) { + const { token, isLoaded } = useAuthContext(); + const router = useRouter(); + const { data: user, isLoading } = useCurrentUser(); + + useEffect(() => { + if (!isLoaded) return; + if (!token) { + router.replace("/login"); + return; + } + if (!isLoading && user && !user.is_admin) { + router.replace("/dashboard"); + } + }, [isLoaded, token, user, isLoading, router]); + + if (!isLoaded || isLoading || !user?.is_admin) return null; + + return <>{children}; +} diff --git a/k-tv-frontend/app/(main)/admin/page.tsx b/k-tv-frontend/app/(main)/admin/page.tsx index 8adc1cd..7003564 100644 --- a/k-tv-frontend/app/(main)/admin/page.tsx +++ b/k-tv-frontend/app/(main)/admin/page.tsx @@ -1,21 +1,15 @@ "use client"; import { useState, useEffect } from "react"; -import { useRouter } from "next/navigation"; import { useAuthContext } from "@/context/auth-context"; import { useActivityLog, useServerLogs } from "@/hooks/use-admin"; import { ServerLogsPanel } from "./components/server-logs-panel"; import { ActivityLogPanel } from "./components/activity-log-panel"; +import { ProviderSettingsPanel } from "./components/provider-settings-panel"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; export default function AdminPage() { - const { token, isLoaded } = useAuthContext(); - const router = useRouter(); - - useEffect(() => { - if (isLoaded && !token) { - router.replace("/login"); - } - }, [isLoaded, token, router]); + const { token } = useAuthContext(); const { lines, connected } = useServerLogs(token); const [localLines, setLocalLines] = useState(lines); @@ -27,8 +21,6 @@ export default function AdminPage() { const { data: events = [], isLoading } = useActivityLog(token); - if (!isLoaded || !token) return null; - return (
{/* Page header */} @@ -37,22 +29,47 @@ export default function AdminPage() { System monitoring & logs
- {/* Two-column layout */} -
- {/* Left: server logs */} -
- setLocalLines([])} - /> + +
+ + + Logs + + + Providers + +
- {/* Right: activity log */} -
- -
-
+ + {/* Two-column layout */} +
+ {/* Left: server logs */} +
+ setLocalLines([])} + /> +
+ + {/* Right: activity log */} +
+ +
+
+
+ + + + +
); } diff --git a/k-tv-frontend/app/(main)/components/admin-nav-link.tsx b/k-tv-frontend/app/(main)/components/admin-nav-link.tsx new file mode 100644 index 0000000..5c462f3 --- /dev/null +++ b/k-tv-frontend/app/(main)/components/admin-nav-link.tsx @@ -0,0 +1,17 @@ +"use client"; + +import Link from "next/link"; +import { useCurrentUser } from "@/hooks/use-auth"; + +export function AdminNavLink() { + const { data: user } = useCurrentUser(); + if (!user?.is_admin) return null; + return ( + + Admin + + ); +} diff --git a/k-tv-frontend/app/(main)/layout.tsx b/k-tv-frontend/app/(main)/layout.tsx index 2d7da23..ef26bd7 100644 --- a/k-tv-frontend/app/(main)/layout.tsx +++ b/k-tv-frontend/app/(main)/layout.tsx @@ -1,12 +1,12 @@ import Link from "next/link"; import { type ReactNode } from "react"; import { NavAuth } from "./components/nav-auth"; +import { AdminNavLink } from "./components/admin-nav-link"; const NAV_LINKS = [ { href: "/tv", label: "TV" }, { href: "/guide", label: "Guide" }, { href: "/dashboard", label: "Dashboard" }, - { href: "/admin", label: "Admin" }, { href: "/docs", label: "Docs" }, ]; @@ -33,6 +33,9 @@ export default function MainLayout({ children }: { children: ReactNode }) { ))} +
  • + +
  • diff --git a/k-tv-frontend/hooks/use-admin-providers.ts b/k-tv-frontend/hooks/use-admin-providers.ts new file mode 100644 index 0000000..686fcff --- /dev/null +++ b/k-tv-frontend/hooks/use-admin-providers.ts @@ -0,0 +1,52 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import { useAuthContext } from "@/context/auth-context"; + +export function useProviderConfigs() { + const { token } = useAuthContext(); + return useQuery({ + queryKey: ["admin", "providers"], + queryFn: () => api.admin.providers.getProviders(token!), + enabled: !!token, + }); +} + +export function useUpdateProvider() { + const { token } = useAuthContext(); + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ + type, + payload, + }: { + type: string; + payload: { config_json: Record; enabled: boolean }; + }) => api.admin.providers.updateProvider(token!, type, payload), + onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "providers"] }), + }); +} + +export function useDeleteProvider() { + const { token } = useAuthContext(); + const qc = useQueryClient(); + return useMutation({ + mutationFn: (type: string) => + api.admin.providers.deleteProvider(token!, type), + onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "providers"] }), + }); +} + +export function useTestProvider() { + const { token } = useAuthContext(); + return useMutation({ + mutationFn: ({ + type, + payload, + }: { + type: string; + payload: { config_json: Record; enabled: boolean }; + }) => api.admin.providers.testProvider(token!, type, payload), + }); +} diff --git a/k-tv-frontend/lib/api.ts b/k-tv-frontend/lib/api.ts index 8a857ab..a988a7f 100644 --- a/k-tv-frontend/lib/api.ts +++ b/k-tv-frontend/lib/api.ts @@ -15,6 +15,8 @@ import type { TranscodeSettings, TranscodeStats, ActivityEvent, + ProviderConfig, + ProviderTestResult, } from "@/lib/types"; const API_BASE = @@ -179,6 +181,36 @@ export const api = { admin: { activity: (token: string) => request("/admin/activity", { token }), + + providers: { + getProviders: (token: string) => + request("/admin/providers", { token }), + + updateProvider: ( + token: string, + type: string, + payload: { config_json: Record; enabled: boolean }, + ) => + request(`/admin/providers/${type}`, { + method: "PUT", + body: JSON.stringify(payload), + token, + }), + + deleteProvider: (token: string, type: string) => + request(`/admin/providers/${type}`, { method: "DELETE", token }), + + testProvider: ( + token: string, + type: string, + payload: { config_json: Record; enabled: boolean }, + ) => + request(`/admin/providers/${type}/test`, { + method: "POST", + body: JSON.stringify(payload), + token, + }), + }, }, schedule: { diff --git a/k-tv-frontend/lib/types.ts b/k-tv-frontend/lib/types.ts index af70dbd..31fb9f3 100644 --- a/k-tv-frontend/lib/types.ts +++ b/k-tv-frontend/lib/types.ts @@ -131,6 +131,18 @@ export interface ConfigResponse { providers: ProviderInfo[]; /** Primary provider capabilities — kept for backward compat. */ provider_capabilities: ProviderCapabilities; + available_provider_types: string[]; +} + +export interface ProviderConfig { + provider_type: string; + config_json: Record; + enabled: boolean; +} + +export interface ProviderTestResult { + ok: boolean; + message: string; } // Auth @@ -145,6 +157,7 @@ export interface UserResponse { id: string; email: string; created_at: string; + is_admin: boolean; } // Channels