Rebase services-as-hub-ia onto mobile-responsive-parity

Combine both branches into a single coherent branch:
- Full mobile responsive parity (useIsMobile, MobileCardRow, SheetForm,
  .mobile-touch-target, mobile cards, SheetForm forms, 44px targets,
  dirty-state confirm, TablePagination, refetchIntervalInBackground).
- Full services-as-hub IA (data-driven nav, service-page tab skeleton,
  new service types, Authentik directory + messaging, named dashboards,
  legacy routes 404, Observability split, Jellyseerr absorbed).

Enhancement: service tabs now use mobile-parity primitives:
- MediaTab: MobileCardRow below md (title/size/HDR/library/year) +
  TablePagination; DataTable at md+ (desktop branch preserved).
- FilesTab: MobileCardRow below md (name/type/size/modified) +
  handleRowClick; DataTable at md+.
- ServicePage: SheetForm branch below md (open-on-mount, sticky header
  + save bar, cancel navigates back to /services, dirty-state guard).
- Dashboard: single-column + section anchors below md (from mobile-parity)
  + empty-state CTA (from services-hub).
- App.tsx: useIsMobile() replaces inline matchMedia (from mobile-parity)
  + data-driven useNavItems (from services-hub).
- Backup tables (BackupAlerts/Jobs/Runs) already have MobileCardRow from
  mobile-parity; JobsTab inherits mobile behavior through its sub-components.

Conflict resolutions:
- Backend: entirely from services-hub (mobile didn't touch it).
- Deleted pages (Media/FileBrowser/Actions/Users/UsersPage/Applications/
  ObservabilityPage/BackupsPage + hooks/useUsers + tests): kept deleted
  (services-hub deleted them; content moved into service tabs).
- New service-tabs/*: from services-hub, enhanced with mobile patterns.
- App.tsx: services-hub's data-driven nav + mobile-parity's useIsMobile.
- Dashboard.tsx: merged (services-hub CTA + mobile-parity sections/anchors).
- ServicePage.tsx: services-hub's tab skeleton + mobile-parity's SheetForm.
- Primitives (useIsMobile/mobile-card/sheet-form/etc.): from mobile-parity.

117 frontend tests pass (mobile-parity's 122 - 5 deleted page tests +
services-hub's new tab/dashboard tests); 271 backend tests pass; lint/
build green both sides.
This commit is contained in:
Developer
2026-06-26 20:55:00 +00:00
parent b583d5a365
commit 01527ae4f0
75 changed files with 5343 additions and 4926 deletions
+259 -6
View File
@@ -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;
@@ -195,7 +215,7 @@ function CreateServiceDialog({
{!draft ? (
<div className="flex flex-col gap-2">
{types.map((t) => (
<Button className="mobile-touch-target"
<Button
key={t.service_type}
variant="outline"
onClick={() => setDraft(emptyDraft(t.service_type))}
@@ -234,7 +254,6 @@ function CreateServiceDialog({
<div className="flex items-center gap-2">
<Switch
id="service-enabled"
className="mobile-touch-target"
checked={draft.enabled}
onCheckedChange={(checked) =>
setDraft({ ...draft, enabled: checked })
@@ -258,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<string | null>(null);
const [linkDashId, setLinkDashId] = useState<string | null>(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 (
<SectionCard
title="Dashboards"
description="Named dashboards appear in the top nav. Compose them from pinned service links."
action={
<Button variant="outline" onClick={() => setCreateOpen(true)}>
<Plus className="mr-1 h-3 w-3" />
New dashboard
</Button>
}
>
{dashboards.length === 0 ? (
<p className="text-sm text-muted-foreground">
No named dashboards yet. Create one to add pinned service links.
</p>
) : (
<div className="flex flex-col gap-3">
{[...dashboards]
.sort((a, b) => a.sort_order - b.sort_order)
.map((d, idx, arr) => (
<div key={d.id} className="rounded border p-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="font-medium">{d.label}</span>
<Badge variant="outline">/{d.slug}</Badge>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={idx === 0}
onClick={() => reorder(d.id, -1)}
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={idx === arr.length - 1}
onClick={() => reorder(d.id, 1)}
>
<ChevronDown className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => setDeleteId(d.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
{Array.isArray(d.payload.items) &&
(d.payload.items as unknown[]).length > 0 ? (
<span className="text-xs text-muted-foreground">
{(d.payload.items as unknown[]).length} pinned link(s)
</span>
) : (
<span className="text-xs text-muted-foreground">
No links yet
</span>
)}
</div>
<div className="mt-2 flex flex-wrap items-end gap-2">
<Field label="Link label" htmlFor={`link-label-${d.id}`}>
<Input
id={`link-label-${d.id}`}
className="w-40"
placeholder="My Jellyfin"
value={linkDashId === d.id ? linkLabel : ""}
onChange={(e) => {
setLinkDashId(d.id);
setLinkLabel(e.target.value);
}}
/>
</Field>
<div className="flex flex-col gap-1.5">
<Label htmlFor={`link-target-${d.id}`}>Service</Label>
<Select
value={linkDashId === d.id ? linkTarget : ""}
onValueChange={(v) => {
setLinkDashId(d.id);
setLinkTarget(v);
}}
>
<SelectTrigger
id={`link-target-${d.id}`}
className="w-56"
>
<SelectValue placeholder="Pick a service" />
</SelectTrigger>
<SelectContent>
{enabledServices.map((s) => (
<SelectItem
key={s.id}
value={serviceLinkTarget(s.service_type, s.id)}
>
{s.name} ({s.service_type})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
variant="outline"
size="sm"
disabled={
linkDashId !== d.id ||
!linkLabel.trim() ||
!linkTarget.trim()
}
onClick={addPinnedLink}
>
Add link
</Button>
</div>
</div>
))}
</div>
)}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>New dashboard</DialogTitle>
</DialogHeader>
<Field label="Label" htmlFor="dash-label">
<Input
id="dash-label"
placeholder="Storage overview"
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") createDashboard();
}}
/>
</Field>
<DialogFooter
onCancel={() => setCreateOpen(false)}
onConfirm={createDashboard}
confirmLabel="Create"
confirmDisabled={!newLabel.trim() || saveDashboard.isPending}
/>
</DialogContent>
</Dialog>
<ConfirmDialog
open={Boolean(deleteId)}
title="Delete dashboard?"
message="This removes the named dashboard and its pinned links."
confirmLabel="Delete"
onCancel={() => setDeleteId(null)}
onConfirm={() => {
if (deleteId) deleteDashboard.mutate(deleteId);
setDeleteId(null);
}}
/>
</SectionCard>
);
}
export function ServicesPage() {
const navigate = useNavigate();
const { data: services = [] } = useServiceInstances();
@@ -287,7 +539,7 @@ export function ServicesPage() {
title="Services"
description="External services the app talks to. Configure URLs and API keys here; they are encrypted at rest."
action={
<Button variant="outline" onClick={() => setCreateOpen(true)} className="mobile-touch-target">
<Button variant="outline" onClick={() => setCreateOpen(true)}>
<Plus className="mr-1 h-3 w-3" />
Add service
</Button>
@@ -329,7 +581,6 @@ export function ServicesPage() {
<Button
variant="ghost"
size="sm"
className="mobile-touch-target"
onClick={() =>
navigate(`/services/${s.service_type}/${s.id}`)
}
@@ -339,7 +590,7 @@ export function ServicesPage() {
<Button
variant="ghost"
size="icon"
className="mobile-touch-target h-8 w-8 text-destructive"
className="h-8 w-8 text-destructive"
onClick={() => setDeleteId(s.id)}
>
<Trash2 className="h-4 w-4" />
@@ -354,6 +605,8 @@ export function ServicesPage() {
)}
</SectionCard>
<DashboardManagementCard />
<CreateServiceDialog
open={createOpen}
onClose={() => setCreateOpen(false)}