mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-06-03 23:12:56 +00:00
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:
@@ -0,0 +1,110 @@
|
||||
import type {
|
||||
Dashboard,
|
||||
Group,
|
||||
Service,
|
||||
WidgetInstance,
|
||||
WidgetData,
|
||||
AssetFile,
|
||||
CreateGroupRequest,
|
||||
PatchGroupRequest,
|
||||
ServiceRequest,
|
||||
LayoutRequest,
|
||||
WidgetRequest,
|
||||
} from "./schema";
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8080";
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
headers: { "Content-Type": "application/json", ...init?.headers },
|
||||
...init,
|
||||
});
|
||||
if (res.status === 204) return undefined as T;
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: res.statusText }));
|
||||
throw new ApiError(res.status, err.code || "unknown", err.message || "Request failed", err.details);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public code: string,
|
||||
message: string,
|
||||
public details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dashboard ──
|
||||
export function getDashboard(): Promise<Dashboard> {
|
||||
return request("/api/v1/dashboard");
|
||||
}
|
||||
|
||||
// ── Groups ──
|
||||
export function listGroups(): Promise<Group[]> {
|
||||
return request("/api/v1/groups");
|
||||
}
|
||||
export function createGroup(body: CreateGroupRequest): Promise<Group> {
|
||||
return request("/api/v1/groups", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
export function patchGroup(id: string, body: PatchGroupRequest): Promise<Group> {
|
||||
return request(`/api/v1/groups/${id}`, { method: "PATCH", body: JSON.stringify(body) });
|
||||
}
|
||||
export function deleteGroup(id: string, moveServices = false): Promise<void> {
|
||||
return request(`/api/v1/groups/${id}?moveServicesToUngrouped=${moveServices}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// ── Services ──
|
||||
export function listServices(): Promise<Service[]> {
|
||||
return request("/api/v1/services");
|
||||
}
|
||||
export function createService(body: ServiceRequest): Promise<Service> {
|
||||
return request("/api/v1/services", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
export function patchService(id: string, body: ServiceRequest): Promise<Service> {
|
||||
return request(`/api/v1/services/${id}`, { method: "PATCH", body: JSON.stringify(body) });
|
||||
}
|
||||
export function deleteService(id: string): Promise<void> {
|
||||
return request(`/api/v1/services/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// ── Layout ──
|
||||
export function putLayout(body: LayoutRequest): Promise<Dashboard> {
|
||||
return request("/api/v1/layout", { method: "PUT", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
// ── Assets ──
|
||||
export async function uploadIcon(file: File): Promise<AssetFile> {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const res = await fetch(`${API_BASE}/api/v1/assets/icons`, { method: "POST", body: form });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: res.statusText }));
|
||||
throw new ApiError(res.status, err.code || "unknown", err.message || "Upload failed");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Widgets ──
|
||||
export function listWidgets(): Promise<WidgetInstance[]> {
|
||||
return request("/api/v1/widgets");
|
||||
}
|
||||
export function createWidget(body: WidgetRequest): Promise<WidgetInstance> {
|
||||
return request("/api/v1/widgets", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
export function patchWidget(id: string, body: WidgetRequest): Promise<WidgetInstance> {
|
||||
return request(`/api/v1/widgets/${id}`, { method: "PATCH", body: JSON.stringify(body) });
|
||||
}
|
||||
export function deleteWidget(id: string): Promise<void> {
|
||||
return request(`/api/v1/widgets/${id}`, { method: "DELETE" });
|
||||
}
|
||||
export function getWidgetData(id: string): Promise<WidgetData> {
|
||||
return request(`/api/v1/widgets/${id}/data`);
|
||||
}
|
||||
export function refreshWidget(id: string): Promise<WidgetData> {
|
||||
return request(`/api/v1/widgets/${id}/refresh`, { method: "POST" });
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import * as api from "./client";
|
||||
import type {
|
||||
Dashboard,
|
||||
CreateGroupRequest,
|
||||
PatchGroupRequest,
|
||||
ServiceRequest,
|
||||
LayoutRequest,
|
||||
WidgetRequest,
|
||||
} from "./schema";
|
||||
|
||||
const DASHBOARD_KEY = ["dashboard"];
|
||||
const WIDGETS_KEY = ["widgets"];
|
||||
|
||||
export function useDashboard() {
|
||||
return useQuery({ queryKey: DASHBOARD_KEY, queryFn: api.getDashboard });
|
||||
}
|
||||
|
||||
export function useCreateGroup() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: CreateGroupRequest) => api.createGroup(body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_KEY }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateGroup() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...body }: PatchGroupRequest & { id: string }) => api.patchGroup(id, body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_KEY }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteGroup() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, moveServices }: { id: string; moveServices?: boolean }) =>
|
||||
api.deleteGroup(id, moveServices),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_KEY }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateService() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: ServiceRequest) => api.createService(body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_KEY }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateService() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...body }: ServiceRequest & { id: string }) => api.patchService(id, body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_KEY }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteService() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.deleteService(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_KEY }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateLayout() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: LayoutRequest) => api.putLayout(body),
|
||||
onMutate: async () => {
|
||||
await qc.cancelQueries({ queryKey: DASHBOARD_KEY });
|
||||
const prev = qc.getQueryData<Dashboard>(DASHBOARD_KEY);
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData(DASHBOARD_KEY, ctx.prev);
|
||||
},
|
||||
onSettled: () => qc.invalidateQueries({ queryKey: DASHBOARD_KEY }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadIcon() {
|
||||
return useMutation({ mutationFn: (file: File) => api.uploadIcon(file) });
|
||||
}
|
||||
|
||||
export function useCreateWidget() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: WidgetRequest) => api.createWidget(body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_KEY }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateWidget() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...body }: WidgetRequest & { id: string }) => api.patchWidget(id, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: DASHBOARD_KEY });
|
||||
qc.invalidateQueries({ queryKey: WIDGETS_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteWidget() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.deleteWidget(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_KEY }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useWidgetData(widgetId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["widget-data", widgetId],
|
||||
queryFn: () => api.getWidgetData(widgetId!),
|
||||
enabled: !!widgetId,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRefreshWidget() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.refreshWidget(id),
|
||||
onSuccess: (_data, id) => {
|
||||
qc.invalidateQueries({ queryKey: ["widget-data", id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export function makeQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let browserQueryClient: QueryClient | undefined;
|
||||
|
||||
export function getQueryClient() {
|
||||
if (typeof window === "undefined") return makeQueryClient();
|
||||
if (!browserQueryClient) browserQueryClient = makeQueryClient();
|
||||
return browserQueryClient;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Auto-generated from ../openapi/openapi.yaml
|
||||
// Run: npm run api:generate
|
||||
|
||||
export interface Dashboard {
|
||||
groups: Group[];
|
||||
ungroupedServices: Service[];
|
||||
widgets: WidgetInstance[];
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: string;
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
collapsed: boolean;
|
||||
services: Service[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Service {
|
||||
id: string;
|
||||
groupId: string | null;
|
||||
name: string;
|
||||
iconUrl: string | null;
|
||||
iconAssetId: string | null;
|
||||
sortOrder: number;
|
||||
urls: ServiceUrl[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ServiceUrl {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: "local" | "external" | "custom";
|
||||
url: string;
|
||||
sortOrder: number;
|
||||
isPrimary: boolean;
|
||||
}
|
||||
|
||||
export interface WidgetInstance {
|
||||
id: string;
|
||||
type: "clock" | "image" | "pihole" | "memos" | "immich";
|
||||
title: string;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
config: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface WidgetData {
|
||||
widgetId: string;
|
||||
status: "fresh" | "stale" | "error";
|
||||
data?: Record<string, unknown>;
|
||||
error?: string | null;
|
||||
fetchedAt?: string | null;
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
||||
export interface AssetFile {
|
||||
id: string;
|
||||
originalName: string;
|
||||
storedName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
publicPath: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
code:
|
||||
| "validation_error"
|
||||
| "not_found"
|
||||
| "conflict"
|
||||
| "upload_too_large"
|
||||
| "unsupported_media_type"
|
||||
| "widget_fetch_failed"
|
||||
| "internal_error";
|
||||
message: string;
|
||||
details: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface CreateGroupRequest {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface PatchGroupRequest {
|
||||
name?: string;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
export interface ServiceRequest {
|
||||
groupId?: string | null;
|
||||
name: string;
|
||||
iconUrl?: string | null;
|
||||
iconAssetId?: string | null;
|
||||
urls: ServiceUrlInput[];
|
||||
}
|
||||
|
||||
export interface ServiceUrlInput {
|
||||
id?: string;
|
||||
label: string;
|
||||
kind: "local" | "external" | "custom";
|
||||
url: string;
|
||||
isPrimary?: boolean;
|
||||
}
|
||||
|
||||
export interface LayoutRequest {
|
||||
groupIds: string[];
|
||||
widgetIds: string[];
|
||||
ungroupedServiceIds: string[];
|
||||
groupServices: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export interface WidgetRequest {
|
||||
type: "clock" | "image" | "pihole" | "memos" | "immich";
|
||||
title: string;
|
||||
enabled?: boolean;
|
||||
config: ClockWidgetConfig | ImageWidgetConfig | PiHoleWidgetConfig | MemosWidgetConfig | ImmichWidgetConfig;
|
||||
}
|
||||
|
||||
export interface ClockWidgetConfig {
|
||||
timezones?: string[];
|
||||
}
|
||||
|
||||
export interface ImageWidgetConfig {
|
||||
imageUrl: string;
|
||||
linkUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface PiHoleWidgetConfig {
|
||||
baseUrl: string;
|
||||
apiToken: string;
|
||||
}
|
||||
|
||||
export interface MemosWidgetConfig {
|
||||
baseUrl: string;
|
||||
apiToken: string;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface ImmichWidgetConfig {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
@@ -0,0 +1,26 @@
|
||||
export type Theme = "light" | "dark" | "casaos";
|
||||
|
||||
const STORAGE_KEY = "dash-theme";
|
||||
|
||||
export function getStoredTheme(): Theme {
|
||||
if (typeof window === "undefined") return "dark";
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === "light" || stored === "dark" || stored === "casaos") return stored;
|
||||
return "dark";
|
||||
}
|
||||
|
||||
export function setStoredTheme(theme: Theme) {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(STORAGE_KEY, theme);
|
||||
}
|
||||
|
||||
export function applyTheme(theme: Theme) {
|
||||
if (typeof document === "undefined") return;
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
}
|
||||
|
||||
export const themeLabels: Record<Theme, string> = {
|
||||
light: "Light",
|
||||
dark: "Dark",
|
||||
casaos: "CasaOS",
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { cn } from "@/lib/utils";
|
||||
Reference in New Issue
Block a user