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,285 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import type { Service, ServiceUrl } from "@/lib/api/schema";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MoreVertical, ExternalLink, Pencil, Trash2, GripVertical, Globe, Home, Settings } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTheme } from "@/components/providers";
|
||||
|
||||
function getInitials(name: string) {
|
||||
const words = name.trim().split(/\s+/);
|
||||
if (words.length >= 2) return (words[0][0] + words[1][0]).toUpperCase();
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function extractHost(url: string) {
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function getIconUrl(service: Service) {
|
||||
if (service.iconUrl) return service.iconUrl;
|
||||
if (service.iconAssetId) return `/uploads/icons/${service.iconAssetId}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function kindIcon(kind: string) {
|
||||
switch (kind) {
|
||||
case "local": return <Home className="h-3 w-3" />;
|
||||
case "external": return <Globe className="h-3 w-3" />;
|
||||
default: return <Settings className="h-3 w-3" />;
|
||||
}
|
||||
}
|
||||
|
||||
function kindBadgeClass(kind: string) {
|
||||
switch (kind) {
|
||||
case "local": return "badge-local";
|
||||
case "external": return "badge-external";
|
||||
default: return "badge-custom";
|
||||
}
|
||||
}
|
||||
|
||||
function useServicePing(url: string | undefined) {
|
||||
const [status, setStatus] = useState<"up" | "down" | "unknown">("unknown");
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) return;
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
fetch(url, { method: "HEAD", mode: "no-cors", signal: controller.signal })
|
||||
.then(() => { if (!cancelled) setStatus("up"); })
|
||||
.catch(() => { if (!cancelled) setStatus("down"); })
|
||||
.finally(() => clearTimeout(timer));
|
||||
|
||||
return () => { cancelled = true; controller.abort(); };
|
||||
}, [url]);
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
function StatusDot({ status }: { status: "up" | "down" | "unknown" }) {
|
||||
if (status === "unknown") return null;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-card",
|
||||
status === "up" && "bg-emerald-500",
|
||||
status === "down" && "bg-red-500"
|
||||
)}
|
||||
title={status === "up" ? "Online" : "Offline"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function UrlPickerDialog({
|
||||
urls,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
urls: ServiceUrl[];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Open App</DialogTitle>
|
||||
<DialogDescription>Choose which URL to open</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-2">
|
||||
{urls.map((u) => (
|
||||
<a
|
||||
key={u.id}
|
||||
href={u.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between rounded-xl border border-border bg-card px-4 py-3 text-sm transition-all hover:bg-accent hover:border-border"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Badge variant="secondary" className={cn("gap-1 text-[10px] px-2 py-0.5 font-medium uppercase", kindBadgeClass(u.kind))}>
|
||||
{kindIcon(u.kind)}
|
||||
{u.kind}
|
||||
</Badge>
|
||||
<span className="font-medium truncate">{u.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-xs text-muted-foreground hidden sm:inline">{extractHost(u.url)}</span>
|
||||
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServiceCard({
|
||||
service,
|
||||
onEdit,
|
||||
onDelete,
|
||||
isDragging = false,
|
||||
dragHandleProps,
|
||||
}: {
|
||||
service: Service;
|
||||
onEdit: (s: Service) => void;
|
||||
onDelete: (id: string) => void;
|
||||
isDragging?: boolean;
|
||||
dragHandleProps?: React.HTMLAttributes<HTMLDivElement>;
|
||||
}) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const { theme } = useTheme();
|
||||
const isCasaOS = theme === "casaos";
|
||||
|
||||
const handleClick = () => {
|
||||
if (service.urls.length === 1) {
|
||||
window.open(service.urls[0].url, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
setPickerOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const iconSrc = getIconUrl(service);
|
||||
const primaryUrl = service.urls.find((u) => u.isPrimary) || service.urls[0];
|
||||
const status = useServicePing(primaryUrl?.url);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
className={cn(
|
||||
"service-card group relative cursor-pointer overflow-hidden",
|
||||
isCasaOS
|
||||
? "aspect-square rounded-[24px] border border-border bg-card shadow-[0_4px_16px_rgba(0,0,0,0.2)] hover:shadow-[0_8px_32px_rgba(0,0,0,0.3)] hover:bg-accent"
|
||||
: "aspect-square rounded-2xl border border-border bg-card shadow-[0px_0px_0px_1px_var(--color-border)] hover:bg-accent hover:shadow-border-hover",
|
||||
isDragging && "drag-overlay",
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Gradient accent line at top */}
|
||||
<div className={cn(
|
||||
"absolute top-0 left-0 right-0 h-[2px] opacity-0 group-hover:opacity-100 transition-opacity",
|
||||
isCasaOS ? "bg-gradient-to-r from-blue-400/60 via-purple-400/60 to-pink-400/60" : "bg-gradient-to-r from-ring/60 to-ring/20"
|
||||
)} />
|
||||
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2.5 p-4">
|
||||
{dragHandleProps && (
|
||||
<div
|
||||
{...dragHandleProps}
|
||||
className="absolute left-2 top-2 cursor-grab rounded-md p-1 opacity-0 transition-all group-hover:opacity-60 hover:opacity-100 hover:bg-accent"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<GripVertical className={cn("text-muted-foreground", isCasaOS ? "h-5 w-5" : "h-4 w-4")} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Icon container */}
|
||||
<div className={cn(
|
||||
"relative flex items-center justify-center transition-transform duration-300 group-hover:scale-110",
|
||||
isCasaOS ? "h-[52px] w-[52px]" : "h-12 w-12"
|
||||
)}>
|
||||
{iconSrc ? (
|
||||
<img
|
||||
src={iconSrc}
|
||||
alt={service.name}
|
||||
className={cn("h-full w-full object-contain drop-shadow-lg", isCasaOS ? "rounded-2xl" : "rounded-xl")}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
(e.target as HTMLImageElement).nextElementSibling?.classList.remove("hidden");
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-xl font-mono font-bold text-secondary-foreground",
|
||||
isCasaOS
|
||||
? "bg-gradient-to-br from-blue-500/20 to-purple-500/20 text-lg border border-white/10"
|
||||
: "bg-secondary text-sm",
|
||||
iconSrc && "hidden",
|
||||
)}
|
||||
>
|
||||
{getInitials(service.name)}
|
||||
</div>
|
||||
<StatusDot status={status} />
|
||||
</div>
|
||||
|
||||
{/* App name */}
|
||||
<span className={cn(
|
||||
"max-w-full truncate text-center font-semibold leading-tight",
|
||||
isCasaOS ? "text-sm text-white/90" : "text-xs text-foreground"
|
||||
)}>
|
||||
{service.name}
|
||||
</span>
|
||||
|
||||
{/* URL indicator */}
|
||||
{primaryUrl && (
|
||||
<span className="text-[10px] text-muted-foreground truncate max-w-full hidden sm:block">
|
||||
{extractHost(primaryUrl.url)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* URL kind badges */}
|
||||
{service.urls.length > 1 && (
|
||||
<div className="flex gap-1">
|
||||
{service.urls.slice(0, 3).map((u) => (
|
||||
<span
|
||||
key={u.id}
|
||||
className={cn(
|
||||
"text-[9px] px-1.5 py-0.5 rounded-full font-medium uppercase tracking-wider",
|
||||
kindBadgeClass(u.kind)
|
||||
)}
|
||||
>
|
||||
{u.kind}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div
|
||||
className="absolute right-2 top-2 opacity-0 transition-all group-hover:opacity-100"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-lg",
|
||||
isCasaOS ? "text-white/50 hover:text-white hover:bg-white/10" : "hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<MoreVertical className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40 rounded-xl">
|
||||
<DropdownMenuItem onClick={() => onEdit(service)} className="gap-2 text-xs">
|
||||
<Pencil className="h-3.5 w-3.5" /> Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="gap-2 text-xs text-destructive" onClick={() => onDelete(service.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5" /> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Card>
|
||||
{service.urls.length > 1 && (
|
||||
<UrlPickerDialog urls={service.urls} open={pickerOpen} onOpenChange={setPickerOpen} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import type { Service, ServiceUrlInput, ServiceRequest } from "@/lib/api/schema";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus, Trash2, Upload, Star } from "lucide-react";
|
||||
import { useCreateService, useUpdateService, useUploadIcon } from "@/lib/api/hooks";
|
||||
|
||||
interface ServiceFormProps {
|
||||
service?: Service | null;
|
||||
groups: { id: string; name: string }[];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const EMPTY_URL: ServiceUrlInput = { label: "", kind: "local", url: "", isPrimary: false };
|
||||
|
||||
export function ServiceForm({ service, groups, open, onOpenChange }: ServiceFormProps) {
|
||||
const isEdit = !!service;
|
||||
const createMut = useCreateService();
|
||||
const updateMut = useUpdateService();
|
||||
const uploadMut = useUploadIcon();
|
||||
|
||||
const [name, setName] = useState(service?.name || "");
|
||||
const [groupId, setGroupId] = useState<string | null>(service?.groupId || null);
|
||||
const [iconUrl, setIconUrl] = useState(service?.iconUrl || "");
|
||||
const [iconAssetId, setIconAssetId] = useState<string | null>(service?.iconAssetId || null);
|
||||
const [iconMode, setIconMode] = useState<"url" | "upload">("url");
|
||||
const [urls, setUrls] = useState<ServiceUrlInput[]>(
|
||||
service?.urls?.map((u) => ({ id: u.id, label: u.label, kind: u.kind, url: u.url, isPrimary: u.isPrimary })) || [{ ...EMPTY_URL, isPrimary: true }],
|
||||
);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const addUrl = () => setUrls((prev) => [...prev, { ...EMPTY_URL }]);
|
||||
const removeUrl = (idx: number) => setUrls((prev) => prev.filter((_, i) => i !== idx));
|
||||
const updateUrl = (idx: number, field: keyof ServiceUrlInput, value: string | boolean) => {
|
||||
setUrls((prev) => {
|
||||
const next = [...prev];
|
||||
next[idx] = { ...next[idx], [field]: value };
|
||||
if (field === "isPrimary" && value === true) {
|
||||
next.forEach((u, i) => {
|
||||
if (i !== idx) u.isPrimary = false;
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const asset = await uploadMut.mutateAsync(file);
|
||||
setIconAssetId(asset.id);
|
||||
setIconUrl("");
|
||||
} catch {
|
||||
setErrors((prev) => ({ ...prev, icon: "Upload failed" }));
|
||||
}
|
||||
};
|
||||
|
||||
const validate = (): boolean => {
|
||||
const e: Record<string, string> = {};
|
||||
if (!name.trim()) e.name = "Name is required";
|
||||
if (urls.length === 0) e.urls = "At least one URL is required";
|
||||
urls.forEach((u, i) => {
|
||||
if (!u.label.trim()) e[`url-label-${i}`] = "Label required";
|
||||
if (!u.url.trim()) e[`url-${i}`] = "URL required";
|
||||
else if (!/^https?:\/\//.test(u.url)) e[`url-${i}`] = "Must be http(s)";
|
||||
});
|
||||
setErrors(e);
|
||||
return Object.keys(e).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validate()) return;
|
||||
const body: ServiceRequest = {
|
||||
name: name.trim(),
|
||||
groupId,
|
||||
iconUrl: iconMode === "url" && iconUrl ? iconUrl : null,
|
||||
iconAssetId: iconMode === "upload" && iconAssetId ? iconAssetId : null,
|
||||
urls: urls.map((u) => ({ label: u.label.trim(), kind: u.kind, url: u.url.trim(), isPrimary: u.isPrimary })),
|
||||
};
|
||||
try {
|
||||
if (isEdit && service) {
|
||||
await updateMut.mutateAsync({ id: service.id, ...body });
|
||||
} else {
|
||||
await createMut.mutateAsync(body);
|
||||
}
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
setErrors({ submit: err instanceof Error ? err.message : "Failed" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? "Edit App" : "Add App"}</DialogTitle>
|
||||
<DialogDescription>{isEdit ? "Update app details" : "Add a new app to your dashboard"}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Jellyfin" />
|
||||
{errors.name && <span className="text-xs text-destructive">{errors.name}</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Icon</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant={iconMode === "url" ? "secondary" : "ghost"} size="sm" onClick={() => setIconMode("url")}>
|
||||
URL
|
||||
</Button>
|
||||
<Button type="button" variant={iconMode === "upload" ? "secondary" : "ghost"} size="sm" onClick={() => setIconMode("upload")}>
|
||||
Upload
|
||||
</Button>
|
||||
</div>
|
||||
{iconMode === "url" ? (
|
||||
<Input value={iconUrl} onChange={(e) => setIconUrl(e.target.value)} placeholder="https://example.com/icon.png" />
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleFileUpload} />
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => fileRef.current?.click()}>
|
||||
<Upload className="h-3 w-3" /> Choose file
|
||||
</Button>
|
||||
{iconAssetId && <span className="text-xs text-muted-foreground">Uploaded</span>}
|
||||
</div>
|
||||
)}
|
||||
{errors.icon && <span className="text-xs text-destructive">{errors.icon}</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Group</Label>
|
||||
<Select value={groupId || "__none__"} onValueChange={(v: string) => setGroupId(v === "__none__" ? null : v)}>
|
||||
<SelectTrigger><SelectValue placeholder="No group" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">No group</SelectItem>
|
||||
{groups.map((g) => (
|
||||
<SelectItem key={g.id} value={g.id}>{g.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>URLs</Label>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={addUrl}>
|
||||
<Plus className="h-3 w-3" /> Add URL
|
||||
</Button>
|
||||
</div>
|
||||
{urls.map((u, i) => (
|
||||
<div key={i} className="flex flex-col gap-1.5 rounded-md border border-border p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="flex-1" value={u.label} onChange={(e) => updateUrl(i, "label", e.target.value)} placeholder="Label" />
|
||||
<Select value={u.kind} onValueChange={(v: string) => updateUrl(i, "kind", v)}>
|
||||
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="local">Local</SelectItem>
|
||||
<SelectItem value="external">External</SelectItem>
|
||||
<SelectItem value="custom">Custom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={() => removeUrl(i)}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="flex-1" value={u.url} onChange={(e) => updateUrl(i, "url", e.target.value)} placeholder="https://" />
|
||||
<Button
|
||||
type="button"
|
||||
variant={u.isPrimary ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => updateUrl(i, "isPrimary", !u.isPrimary)}
|
||||
title="Primary URL"
|
||||
>
|
||||
<Star className={u.isPrimary ? "h-3 w-3 fill-current" : "h-3 w-3"} />
|
||||
</Button>
|
||||
</div>
|
||||
{errors[`url-label-${i}`] && <span className="text-xs text-destructive">{errors[`url-label-${i}`]}</span>}
|
||||
{errors[`url-${i}`] && <span className="text-xs text-destructive">{errors[`url-${i}`]}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{errors.submit && <span className="text-xs text-destructive">{errors.submit}</span>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} disabled={createMut.isPending || updateMut.isPending}>
|
||||
{isEdit ? "Save" : "Add App"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user