refactor(frontend): restructure project layout and update API schema

Relocate frontend source code from `next-app/` to `frontend/` to align with the new project structure. This includes removing the old Next.js boilerplate files and establishing a cleaner workspace.

Additionally, updates the OpenAPI specification to include support for the `immich` widget type and its corresponding configuration schema.

- Move frontend files to `frontend/`
- Delete obsolete `next-app/` directory and its configuration
- Add `immich` widget type to `openapi.yaml`
- Update `FrontendPlan.md` with dashboard refactor and UX direction
This commit is contained in:
Tomas Dvorak
2026-05-04 12:31:34 +02:00
parent b17a06fbba
commit 17a579880f
85 changed files with 9441 additions and 947 deletions
+16
View File
@@ -0,0 +1,16 @@
"use client";
import { handlers } from "./handlers";
let installed = false;
export function installMocks() {
if (installed || typeof window === "undefined") return;
import("msw/browser").then(({ setupWorker }) => {
const worker = setupWorker(...handlers);
worker.start({ onUnhandledRequest: "bypass" });
installed = true;
console.log("[MSW] Mock Service Worker installed");
});
}
+117
View File
@@ -0,0 +1,117 @@
import type { Dashboard, Group, Service, WidgetInstance, WidgetData, AssetFile } from "@/lib/api/schema";
export const FIXTURE_ASSET: AssetFile = {
id: "a1b2c3d4-0000-0000-0000-000000000001",
originalName: "jellyfin.png",
storedName: "jellyfin-abc123.png",
mimeType: "image/png",
sizeBytes: 12345,
publicPath: "/uploads/icons/jellyfin-abc123.png",
createdAt: "2025-01-01T00:00:00Z",
};
export const FIXTURE_SERVICES: Service[] = [
{
id: "s1-0000-0000-0000-000000000001",
groupId: "g1-0000-0000-0000-000000000001",
name: "Jellyfin",
iconUrl: null,
iconAssetId: FIXTURE_ASSET.id,
sortOrder: 0,
urls: [
{ id: "u1", label: "Local", kind: "local", url: "http://jellyfin.local:8096", sortOrder: 0, isPrimary: true },
{ id: "u2", label: "External", kind: "external", url: "https://jellyfin.example.com", sortOrder: 1, isPrimary: false },
],
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-01T00:00:00Z",
},
{
id: "s2-0000-0000-0000-000000000002",
groupId: "g1-0000-0000-0000-000000000001",
name: "Pi-hole",
iconUrl: null,
iconAssetId: null,
sortOrder: 1,
urls: [
{ id: "u3", label: "Dashboard", kind: "local", url: "http://pihole.local/admin", sortOrder: 0, isPrimary: true },
],
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-01T00:00:00Z",
},
{
id: "s3-0000-0000-0000-000000000003",
groupId: null,
name: "Proxmox",
iconUrl: "https://proxmox.com/favicon.ico",
iconAssetId: null,
sortOrder: 0,
urls: [
{ id: "u4", label: "Web UI", kind: "local", url: "https://proxmox.local:8006", sortOrder: 0, isPrimary: true },
],
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-01T00:00:00Z",
},
];
export const FIXTURE_GROUPS: Group[] = [
{
id: "g1-0000-0000-0000-000000000001",
name: "Media",
sortOrder: 0,
collapsed: false,
services: FIXTURE_SERVICES.filter((s) => s.groupId === "g1-0000-0000-0000-000000000001"),
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-01T00:00:00Z",
},
];
export const FIXTURE_WIDGETS: WidgetInstance[] = [
{
id: "w1-0000-0000-0000-000000000001",
type: "clock",
title: "Clock",
enabled: true,
sortOrder: 0,
config: { timezones: ["Europe/Prague", "America/New_York"] },
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-01T00:00:00Z",
},
{
id: "w2-0000-0000-0000-000000000002",
type: "pihole",
title: "Pi-hole Stats",
enabled: true,
sortOrder: 1,
config: { baseUrl: "http://pihole.local", apiToken: "••••••••" },
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-01T00:00:00Z",
},
];
export const FIXTURE_WIDGET_DATA: Record<string, WidgetData> = {
"w1-0000-0000-0000-000000000001": {
widgetId: "w1-0000-0000-0000-000000000001",
status: "fresh",
data: {},
fetchedAt: "2025-01-01T12:00:00Z",
expiresAt: "2025-01-01T12:01:00Z",
},
"w2-0000-0000-0000-000000000002": {
widgetId: "w2-0000-0000-0000-000000000002",
status: "fresh",
data: {
status: "enabled",
ads_blocked_today: 45231,
dns_queries_today: 120000,
ads_percentage_today: 37.69,
},
fetchedAt: "2025-01-01T12:00:00Z",
expiresAt: "2025-01-01T12:01:00Z",
},
};
export const FIXTURE_DASHBOARD: Dashboard = {
groups: FIXTURE_GROUPS,
ungroupedServices: FIXTURE_SERVICES.filter((s) => s.groupId === null),
widgets: FIXTURE_WIDGETS,
};
+93
View File
@@ -0,0 +1,93 @@
import { http, HttpResponse } from "msw";
import { FIXTURE_DASHBOARD, FIXTURE_WIDGET_DATA } from "./fixtures";
const BASE = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8080";
export const handlers = [
http.get(`${BASE}/api/v1/dashboard`, () => {
return HttpResponse.json(FIXTURE_DASHBOARD);
}),
http.get(`${BASE}/api/v1/groups`, () => {
return HttpResponse.json(FIXTURE_DASHBOARD.groups);
}),
http.post(`${BASE}/api/v1/groups`, async ({ request }) => {
const body = await request.json();
const newGroup = {
id: crypto.randomUUID(),
name: (body as { name: string }).name,
sortOrder: FIXTURE_DASHBOARD.groups.length,
collapsed: false,
services: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
return HttpResponse.json(newGroup, { status: 201 });
}),
http.get(`${BASE}/api/v1/services`, () => {
const all = [...FIXTURE_DASHBOARD.ungroupedServices, ...FIXTURE_DASHBOARD.groups.flatMap((g) => g.services)];
return HttpResponse.json(all);
}),
http.post(`${BASE}/api/v1/services`, async ({ request }) => {
const body = await request.json();
const newService = {
id: crypto.randomUUID(),
...(body as Record<string, unknown>),
sortOrder: 0,
urls: (body as { urls: Record<string, unknown>[] }).urls.map((u, i) => ({ ...u, id: crypto.randomUUID(), sortOrder: i })),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
return HttpResponse.json(newService, { status: 201 });
}),
http.put(`${BASE}/api/v1/layout`, async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ ...FIXTURE_DASHBOARD, ...(body as Record<string, unknown>) });
}),
http.post(`${BASE}/api/v1/assets/icons`, async () => {
return HttpResponse.json(
{
id: crypto.randomUUID(),
originalName: "icon.png",
storedName: "icon-mock.png",
mimeType: "image/png",
sizeBytes: 1024,
publicPath: "/uploads/icons/icon-mock.png",
createdAt: new Date().toISOString(),
},
{ status: 201 },
);
}),
http.get(`${BASE}/api/v1/widgets`, () => {
return HttpResponse.json(FIXTURE_DASHBOARD.widgets);
}),
http.post(`${BASE}/api/v1/widgets`, async ({ request }) => {
const body = await request.json();
const newWidget = {
id: crypto.randomUUID(),
...(body as Record<string, unknown>),
sortOrder: FIXTURE_DASHBOARD.widgets.length,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
return HttpResponse.json(newWidget, { status: 201 });
}),
Object.entries(FIXTURE_WIDGET_DATA).map(([widgetId, data]) =>
http.get(`${BASE}/api/v1/widgets/${widgetId}/data`, () => {
return HttpResponse.json(data);
})
),
http.post(`${BASE}/api/v1/widgets/:widgetId/refresh`, ({ params }) => {
const data = FIXTURE_WIDGET_DATA[params.widgetId as string];
return HttpResponse.json(data || { widgetId: params.widgetId, status: "fresh", data: {}, fetchedAt: new Date().toISOString() });
}),
].flat();