diff --git a/backend/src/media_library_viewer_api/routers/dashboards.py b/backend/src/media_library_viewer_api/routers/dashboards.py index 34b2930..8f28f48 100644 --- a/backend/src/media_library_viewer_api/routers/dashboards.py +++ b/backend/src/media_library_viewer_api/routers/dashboards.py @@ -17,6 +17,14 @@ def list_dashboards(store: SettingsStore = Depends(get_settings_store)) -> list[ return [NamedDashboard(**row) for row in rows] +@router.get("/slug/{slug}") +def get_dashboard_by_slug(slug: str, store: SettingsStore = Depends(get_settings_store)) -> NamedDashboard: + row = store.get_dashboard_by_slug(slug) + if not row: + raise HTTPException(status_code=404, detail="Dashboard not found") + return NamedDashboard(**row) + + @router.post("") def create_dashboard(body: NamedDashboardInput, store: SettingsStore = Depends(get_settings_store)) -> NamedDashboard: row = store.upsert_dashboard(body.model_dump()) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 36b4379..7f0b060 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,6 +15,7 @@ import { useEffect, useMemo, useState } from "react"; import type { LucideIcon } from "lucide-react"; import { AuthProvider, useAuth } from "react-oidc-context"; import { Dashboard } from "./pages/Dashboard"; +import { NamedDashboardPage } from "./pages/NamedDashboardPage"; import { Settings } from "./pages/Settings"; import { ServicePage } from "./pages/ServicePage"; import { ServiceTypePage } from "./pages/ServiceTypePage"; @@ -474,6 +475,7 @@ function AppInner() { }> } /> + } /> } /> } /> } /> + } /> } /> } /> { return get("/api/dashboards"); } +export async function fetchDashboardBySlug( + slug: string, +): Promise { + return get( + `/api/dashboards/slug/${encodeURIComponent(slug)}`, + ); +} + export async function createDashboard( input: NamedDashboardInput, ): Promise { diff --git a/frontend/src/components/PinnedServiceLink.tsx b/frontend/src/components/PinnedServiceLink.tsx new file mode 100644 index 0000000..3543b5f --- /dev/null +++ b/frontend/src/components/PinnedServiceLink.tsx @@ -0,0 +1,57 @@ +import { useNavigate } from "react-router-dom"; +import { Boxes, ChevronRight, type LucideIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +/** + * Pinned service link rendered on named dashboards. A card-shaped shortcut + * that navigates to a service page (or a specific tab via query param). + * + * The `target` is a route path like `/services/jellyfin/svc-1` or + * `/services/ssh_tasks/svc-2?tab=Files`. + */ +export interface PinnedServiceLinkProps { + label: string; + target: string; + icon?: LucideIcon; + className?: string; +} + +export function PinnedServiceLink({ + label, + target, + icon: Icon = Boxes, + className, +}: PinnedServiceLinkProps) { + const navigate = useNavigate(); + return ( + + ); +} + +/** + * Static helper: build a target path for a pinned service link. + * Returns `/services/:type/:id` or with a `?tab=` suffix when provided. + */ +// eslint-disable-next-line react-refresh/only-export-components +export function serviceLinkTarget( + serviceType: string, + serviceId: string, + tab?: string, +): string { + const base = `/services/${serviceType}/${serviceId}`; + return tab ? `${base}?tab=${tab}` : base; +} diff --git a/frontend/src/components/__tests__/PinnedServiceLink.test.tsx b/frontend/src/components/__tests__/PinnedServiceLink.test.tsx new file mode 100644 index 0000000..9df1f3b --- /dev/null +++ b/frontend/src/components/__tests__/PinnedServiceLink.test.tsx @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Routes, Route } from "react-router-dom"; +import userEvent from "@testing-library/user-event"; +import { PinnedServiceLink } from "../PinnedServiceLink"; + +function renderLink() { + return render( + + + + } + /> + target page} + /> + + , + ); +} + +describe("PinnedServiceLink", () => { + it("renders the label", () => { + renderLink(); + expect(screen.getByText("My Jellyfin")).toBeInTheDocument(); + }); + + it("navigates to the target on click", async () => { + const user = userEvent.setup(); + renderLink(); + await user.click(screen.getByText("My Jellyfin")); + expect(screen.getByText("target page")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/hooks/useDashboards.ts b/frontend/src/hooks/useDashboards.ts index bfa8481..b54ce12 100644 --- a/frontend/src/hooks/useDashboards.ts +++ b/frontend/src/hooks/useDashboards.ts @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createDashboard, deleteDashboard, + fetchDashboardBySlug, fetchDashboards, updateDashboard, type NamedDashboardInput, @@ -15,6 +16,15 @@ export function useDashboards() { }); } +export function useDashboardBySlug(slug: string | undefined) { + return useQuery({ + queryKey: ["dashboards", "slug", slug], + queryFn: () => fetchDashboardBySlug(slug!), + enabled: !!slug, + staleTime: 30 * 1000, + }); +} + export function useSaveDashboard() { const queryClient = useQueryClient(); return useMutation({ diff --git a/frontend/src/pages/NamedDashboardPage.tsx b/frontend/src/pages/NamedDashboardPage.tsx new file mode 100644 index 0000000..9c24ba9 --- /dev/null +++ b/frontend/src/pages/NamedDashboardPage.tsx @@ -0,0 +1,91 @@ +import { useMemo } from "react"; +import { useParams } from "react-router-dom"; +import { Boxes } from "lucide-react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useDashboardBySlug } from "../hooks/useDashboards"; +import { PinnedServiceLink } from "../components/PinnedServiceLink"; + +/** + * Payload model for named dashboards (design choice: inline items, not widget + * instance ids). The payload stores an ordered list of items: + * + * ``` + * { items: DashboardItem[] } + * ``` + * + * Where `DashboardItem` is either a pinned service link (this slice) or a + * future widget reference (follow-up). Widget composition on named dashboards + * is deferred — the main Dashboard already has the rich widget config dialog. + */ +interface LinkItem { + type: "link"; + label: string; + target: string; +} + +type DashboardItem = LinkItem; + +function parseItems(payload: Record): DashboardItem[] { + const items = payload.items; + if (!Array.isArray(items)) return []; + return items.filter( + (item): item is LinkItem => + typeof item === "object" && + item !== null && + item.type === "link" && + typeof item.label === "string" && + typeof item.target === "string", + ); +} + +export function NamedDashboardPage() { + const { slug = "" } = useParams<{ slug: string }>(); + const { data: dashboard, isLoading, isError } = useDashboardBySlug(slug); + + const items = useMemo( + () => parseItems(dashboard?.payload ?? {}), + [dashboard?.payload], + ); + + if (isLoading) { + return ; + } + + if (isError || !dashboard) { + return ( + + + Dashboard not found. It may have been deleted or the link is invalid. + + + ); + } + + return ( +
+
+

{dashboard.label}

+
+ {items.length === 0 ? ( + + + This dashboard has no shortcuts yet. Add pinned service links from + the dashboard management panel on the Services page. + + + ) : ( +
+ {items.map((item, index) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/ServicesPage.tsx b/frontend/src/pages/ServicesPage.tsx index 057e5c3..794622d 100644 --- a/frontend/src/pages/ServicesPage.tsx +++ b/frontend/src/pages/ServicesPage.tsx @@ -12,13 +12,31 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { ExternalLink, Plus, Trash2 } from "lucide-react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + ChevronDown, + ChevronUp, + ExternalLink, + Plus, + Trash2, +} from "lucide-react"; import { useDeleteServiceInstance, useSaveServiceInstance, useServiceInstances, } from "../hooks/useServices"; import { useServiceTypes } from "../hooks/useServices"; +import { + useDashboards, + useDeleteDashboard, + useSaveDashboard, +} from "../hooks/useDashboards"; import type { SecretFieldInfo, ServiceInstance, @@ -29,6 +47,8 @@ import { SectionCard } from "../components/SectionCard"; import { ConfirmDialog } from "../components/ConfirmDialog"; import { DialogFooter } from "../components/DialogFooter"; import { getServiceBinding } from "../integrations/registry"; +import { serviceLinkTarget } from "../components/PinnedServiceLink"; +import type { NamedDashboardInput } from "../api/dashboards"; interface CreateDraft { serviceType: string; @@ -257,6 +277,239 @@ function CreateServiceDialog({ ); } +// --- Named dashboards management (Slice 10.3) --- + +function DashboardManagementCard() { + const { data: dashboards = [] } = useDashboards(); + const saveDashboard = useSaveDashboard(); + const deleteDashboard = useDeleteDashboard(); + const { data: services = [] } = useServiceInstances(); + const [createOpen, setCreateOpen] = useState(false); + const [newLabel, setNewLabel] = useState(""); + const [deleteId, setDeleteId] = useState(null); + const [linkDashId, setLinkDashId] = useState(null); + const [linkLabel, setLinkLabel] = useState(""); + const [linkTarget, setLinkTarget] = useState(""); + + const enabledServices = useMemo( + () => services.filter((s) => s.enabled), + [services], + ); + + function createDashboard() { + if (!newLabel.trim()) return; + const input: NamedDashboardInput = { + label: newLabel.trim(), + sort_order: dashboards.length, + payload: { items: [] }, + }; + saveDashboard.mutate(input); + setNewLabel(""); + setCreateOpen(false); + } + + function reorder(dashId: string, direction: -1 | 1) { + const sorted = [...dashboards].sort((a, b) => a.sort_order - b.sort_order); + const idx = sorted.findIndex((d) => d.id === dashId); + const swapIdx = idx + direction; + if (swapIdx < 0 || swapIdx >= sorted.length) return; + const a = sorted[idx]; + const b = sorted[swapIdx]; + saveDashboard.mutate({ + ...a, + sort_order: b.sort_order, + payload: a.payload, + }); + saveDashboard.mutate({ + ...b, + sort_order: a.sort_order, + payload: b.payload, + }); + } + + function addPinnedLink() { + if (!linkDashId || !linkLabel.trim() || !linkTarget.trim()) return; + const dash = dashboards.find((d) => d.id === linkDashId); + if (!dash) return; + const items = Array.isArray(dash.payload.items) + ? (dash.payload.items as unknown[]) + : []; + items.push({ type: "link", label: linkLabel.trim(), target: linkTarget }); + saveDashboard.mutate({ + id: dash.id, + label: dash.label, + sort_order: dash.sort_order, + payload: { items }, + }); + setLinkLabel(""); + setLinkTarget(""); + } + + return ( + setCreateOpen(true)}> + + New dashboard + + } + > + {dashboards.length === 0 ? ( +

+ No named dashboards yet. Create one to add pinned service links. +

+ ) : ( +
+ {[...dashboards] + .sort((a, b) => a.sort_order - b.sort_order) + .map((d, idx, arr) => ( +
+
+
+ {d.label} + /{d.slug} +
+
+ + + +
+
+
+ {Array.isArray(d.payload.items) && + (d.payload.items as unknown[]).length > 0 ? ( + + {(d.payload.items as unknown[]).length} pinned link(s) + + ) : ( + + No links yet + + )} +
+
+ + { + setLinkDashId(d.id); + setLinkLabel(e.target.value); + }} + /> + +
+ + +
+ +
+
+ ))} +
+ )} + + + + + New dashboard + + + setNewLabel(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") createDashboard(); + }} + /> + + setCreateOpen(false)} + onConfirm={createDashboard} + confirmLabel="Create" + confirmDisabled={!newLabel.trim() || saveDashboard.isPending} + /> + + + + setDeleteId(null)} + onConfirm={() => { + if (deleteId) deleteDashboard.mutate(deleteId); + setDeleteId(null); + }} + /> +
+ ); +} + export function ServicesPage() { const navigate = useNavigate(); const { data: services = [] } = useServiceInstances(); @@ -352,6 +605,8 @@ export function ServicesPage() { )} + + setCreateOpen(false)} diff --git a/frontend/src/pages/__tests__/NamedDashboardPage.test.tsx b/frontend/src/pages/__tests__/NamedDashboardPage.test.tsx new file mode 100644 index 0000000..2943fce --- /dev/null +++ b/frontend/src/pages/__tests__/NamedDashboardPage.test.tsx @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { NamedDashboardPage } from "../NamedDashboardPage"; + +vi.mock("../../hooks/useDashboards", () => ({ + useDashboardBySlug: vi.fn(() => ({ data: undefined, isLoading: true })), +})); + +import { useDashboardBySlug } from "../../hooks/useDashboards"; + +function renderPage(slug: string) { + return render( + + + } /> + + , + ); +} + +describe("NamedDashboardPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders loading state", () => { + vi.mocked(useDashboardBySlug).mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + } as never); + renderPage("storage"); + // Skeleton renders during load. + expect(document.querySelector(".h-32")).toBeInTheDocument(); + }); + + it("renders 404 when dashboard not found", () => { + vi.mocked(useDashboardBySlug).mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + } as never); + renderPage("nonexistent"); + expect(screen.getByText(/Dashboard not found/i)).toBeInTheDocument(); + }); + + it("renders pinned links for a known dashboard", () => { + vi.mocked(useDashboardBySlug).mockReturnValue({ + data: { + id: "d1", + label: "Storage", + slug: "storage", + sort_order: 0, + payload: { + items: [ + { + type: "link", + label: "My Jellyfin", + target: "/services/jellyfin/svc-1", + }, + ], + }, + created_at: 1, + updated_at: 1, + }, + isLoading: false, + isError: false, + } as never); + renderPage("storage"); + expect(screen.getByText("Storage")).toBeInTheDocument(); + expect(screen.getByText("My Jellyfin")).toBeInTheDocument(); + }); + + it("renders empty state when dashboard has no items", () => { + vi.mocked(useDashboardBySlug).mockReturnValue({ + data: { + id: "d2", + label: "Empty", + slug: "empty", + sort_order: 0, + payload: {}, + created_at: 1, + updated_at: 1, + }, + isLoading: false, + isError: false, + } as never); + renderPage("empty"); + expect(screen.getByText("Empty")).toBeInTheDocument(); + expect(screen.getByText(/no shortcuts yet/i)).toBeInTheDocument(); + }); +});