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,251 @@
|
||||
"use client";
|
||||
|
||||
import type { WidgetInstance, WidgetData } from "@/lib/api/schema";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { MoreVertical, RefreshCw, Pencil, Trash2, GripVertical, Clock, Shield, ImageIcon, StickyNote, Camera, Activity } from "lucide-react";
|
||||
import { useWidgetData, useRefreshWidget } from "@/lib/api/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTheme } from "@/components/providers";
|
||||
|
||||
const widgetTypeIcons: Record<string, React.ReactNode> = {
|
||||
clock: <Clock className="h-3.5 w-3.5" />,
|
||||
pihole: <Shield className="h-3.5 w-3.5" />,
|
||||
image: <ImageIcon className="h-3.5 w-3.5" />,
|
||||
memos: <StickyNote className="h-3.5 w-3.5" />,
|
||||
immich: <Camera className="h-3.5 w-3.5" />,
|
||||
};
|
||||
|
||||
const widgetTypeColors: Record<string, string> = {
|
||||
clock: "from-blue-500/20 to-cyan-500/20",
|
||||
pihole: "from-emerald-500/20 to-teal-500/20",
|
||||
image: "from-purple-500/20 to-pink-500/20",
|
||||
memos: "from-amber-500/20 to-orange-500/20",
|
||||
immich: "from-rose-500/20 to-red-500/20",
|
||||
};
|
||||
|
||||
export function WidgetCard({
|
||||
widget,
|
||||
onEdit,
|
||||
onDelete,
|
||||
dragHandleProps,
|
||||
}: {
|
||||
widget: WidgetInstance;
|
||||
onEdit: (w: WidgetInstance) => void;
|
||||
onDelete: (id: string) => void;
|
||||
dragHandleProps?: React.HTMLAttributes<HTMLDivElement>;
|
||||
}) {
|
||||
const { data, isLoading, error } = useWidgetData(widget.id);
|
||||
const refreshMut = useRefreshWidget();
|
||||
const { theme } = useTheme();
|
||||
const isCasaOS = theme === "casaos";
|
||||
|
||||
const handleRefresh = () => refreshMut.mutate(widget.id);
|
||||
|
||||
const statusLabel = data?.status === "stale" ? "stale" : data?.status === "error" ? "error" : "";
|
||||
const typeIcon = widgetTypeIcons[widget.type] || <Activity className="h-3.5 w-3.5" />;
|
||||
const typeGradient = widgetTypeColors[widget.type] || "from-muted to-muted";
|
||||
|
||||
return (
|
||||
<Card className={cn(
|
||||
"group relative border-0 overflow-hidden",
|
||||
isCasaOS
|
||||
? "rounded-[20px] bg-card border border-border shadow-[0_4px_16px_rgba(0,0,0,0.15)] hover:shadow-[0_8px_32px_rgba(0,0,0,0.25)] hover:-translate-y-[2px] transition-all duration-300"
|
||||
: "rounded-2xl shadow-[0px_0px_0px_1px_var(--color-border)] hover:shadow-border-hover transition-all duration-200"
|
||||
)}>
|
||||
<div className={cn(
|
||||
"absolute top-0 left-0 right-0 h-1 opacity-60",
|
||||
isCasaOS ? `bg-gradient-to-r ${typeGradient}` : "bg-gradient-to-r from-ring/40 to-transparent"
|
||||
)} />
|
||||
<CardHeader className={cn("flex flex-row items-center justify-between pt-4 pb-2", isCasaOS ? "px-5" : "px-4")}>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
{dragHandleProps && (
|
||||
<div {...dragHandleProps} className="cursor-grab opacity-0 group-hover:opacity-60 transition-opacity rounded-md p-0.5 hover:bg-accent">
|
||||
<GripVertical className={cn("text-muted-foreground", isCasaOS ? "h-5 w-5" : "h-4 w-4")} />
|
||||
</div>
|
||||
)}
|
||||
<div className={cn("flex h-6 w-6 items-center justify-center rounded-md shrink-0", isCasaOS ? "bg-white/10" : "bg-accent")}>
|
||||
{typeIcon}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<CardTitle className="text-xs font-semibold uppercase tracking-wide truncate">
|
||||
{widget.title}
|
||||
</CardTitle>
|
||||
{statusLabel && (
|
||||
<span className={cn(
|
||||
"text-[9px] px-1.5 py-0.5 rounded-full font-medium uppercase shrink-0",
|
||||
statusLabel === "stale" ? "bg-amber-500/15 text-amber-400" : "bg-destructive/15 text-destructive"
|
||||
)}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<Button variant="ghost" size="icon" className={cn("relative z-10 pointer-events-auto rounded-lg h-7 w-7", isCasaOS ? "text-white/50 hover:text-white hover:bg-white/10" : "hover:bg-accent")} onClick={handleRefresh} disabled={refreshMut.isPending}>
|
||||
<RefreshCw className={cn(refreshMut.isPending && "animate-spin", isCasaOS ? "h-4 w-4" : "h-3.5 w-3.5")} />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className={cn("rounded-lg h-7 w-7", 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(widget)} 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(widget.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5" /> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className={cn(isCasaOS ? "px-5 pb-5 pt-1" : "px-4 pb-4 pt-1")}>
|
||||
{isLoading ? (
|
||||
<span className="font-mono text-xs text-muted-foreground">[LOADING...]</span>
|
||||
) : error || data?.status === "error" ? (
|
||||
<span className="font-mono text-xs text-destructive">[ERROR: {data?.error || "Failed to load"}]</span>
|
||||
) : (
|
||||
<WidgetContent widget={widget} data={data} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function WidgetContent({ widget, data }: { widget: WidgetInstance; data?: WidgetData }) {
|
||||
switch (widget.type) {
|
||||
case "clock":
|
||||
return <ClockContent config={widget.config} data={data} />;
|
||||
case "image":
|
||||
return <ImageContent config={widget.config} />;
|
||||
case "pihole":
|
||||
return <PiHoleContent data={data} />;
|
||||
case "memos":
|
||||
return <MemosContent data={data} />;
|
||||
case "immich":
|
||||
return <ImmichContent data={data} />;
|
||||
default:
|
||||
return <span className="font-mono text-xs text-muted-foreground">Unknown widget type</span>;
|
||||
}
|
||||
}
|
||||
|
||||
function ClockContent({ config }: { config: Record<string, unknown>; data?: WidgetData }) {
|
||||
const timezones = (config.timezones as string[]) || [];
|
||||
const now = new Date();
|
||||
const localTime = now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
const localDate = now.toLocaleDateString([], { weekday: "long", month: "long", day: "numeric" });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="font-mono text-3xl tabular-nums tracking-tight text-foreground">{localTime}</div>
|
||||
<div className="text-xs text-muted-foreground font-medium">{localDate}</div>
|
||||
{timezones.length > 0 && (
|
||||
<div className="mt-2 flex flex-col gap-1.5 border-t border-border/30 pt-2">
|
||||
{timezones.map((tz) => {
|
||||
try {
|
||||
const t = new Date().toLocaleTimeString([], { timeZone: tz, hour: "2-digit", minute: "2-digit" });
|
||||
return (
|
||||
<div key={tz} className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground text-[11px]">{tz.split("/").pop()?.replace("_", " ")}</span>
|
||||
<span className="font-mono tabular-nums text-foreground">{t}</span>
|
||||
</div>
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageContent({ config }: { config: Record<string, unknown> }) {
|
||||
const imageUrl = config.imageUrl as string;
|
||||
const linkUrl = config.linkUrl as string | null;
|
||||
|
||||
const img = (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Widget image"
|
||||
className="max-h-48 w-full rounded-xl object-cover border border-border/20 shadow-sm"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (linkUrl) {
|
||||
return <a href={linkUrl} target="_blank" rel="noopener noreferrer" className="block rounded-xl overflow-hidden">{img}</a>;
|
||||
}
|
||||
return img;
|
||||
}
|
||||
|
||||
function PiHoleContent({ data }: { data?: WidgetData }) {
|
||||
const d = data?.data as Record<string, unknown> | undefined;
|
||||
if (!d) return <span className="font-mono text-xs text-muted-foreground">No data</span>;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-lg bg-emerald-500/10 p-2.5">
|
||||
<div className="text-[10px] uppercase tracking-wider text-emerald-400 font-medium mb-0.5">Status</div>
|
||||
<div className={cn("text-sm font-semibold", d.status === "enabled" ? "text-emerald-400" : "text-destructive")}>
|
||||
{String(d.status || "unknown")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-blue-500/10 p-2.5">
|
||||
<div className="text-[10px] uppercase tracking-wider text-blue-400 font-medium mb-0.5">Blocked</div>
|
||||
<div className="font-mono text-sm font-semibold text-foreground">{String(d.ads_blocked_today || "0")}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-purple-500/10 p-2.5">
|
||||
<div className="text-[10px] uppercase tracking-wider text-purple-400 font-medium mb-0.5">Queries</div>
|
||||
<div className="font-mono text-sm font-semibold text-foreground">{String(d.dns_queries_today || "0")}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-amber-500/10 p-2.5">
|
||||
<div className="text-[10px] uppercase tracking-wider text-amber-400 font-medium mb-0.5">% Blocked</div>
|
||||
<div className="font-mono text-sm font-semibold text-foreground">{String(d.ads_percentage_today || "0")}%</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemosContent({ data }: { data?: WidgetData }) {
|
||||
const d = data?.data as Record<string, unknown> | undefined;
|
||||
const memos = (d?.memos as Array<Record<string, unknown>>) || [];
|
||||
if (memos.length === 0) return <span className="font-mono text-xs text-muted-foreground">No memos</span>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 max-h-40 overflow-y-auto pr-1">
|
||||
{memos.slice(0, 5).map((m, i) => (
|
||||
<div key={i} className="rounded-lg bg-amber-500/10 p-2.5 border border-amber-500/10">
|
||||
<div className="text-[11px] leading-relaxed line-clamp-2 text-foreground/90">
|
||||
{String(m.content || m.snippet || "")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImmichContent({ data }: { data?: WidgetData }) {
|
||||
const d = data?.data as Record<string, unknown> | undefined;
|
||||
if (!d) return <span className="font-mono text-xs text-muted-foreground">No data</span>;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-lg bg-blue-500/10 p-2.5">
|
||||
<div className="text-[10px] uppercase tracking-wider text-blue-400 font-medium mb-0.5">Photos</div>
|
||||
<div className="font-mono text-sm font-semibold text-foreground">{String(d.photos || "0")}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-rose-500/10 p-2.5">
|
||||
<div className="text-[10px] uppercase tracking-wider text-rose-400 font-medium mb-0.5">Videos</div>
|
||||
<div className="font-mono text-sm font-semibold text-foreground">{String(d.videos || "0")}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { WidgetInstance, WidgetRequest } 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 { Switch } from "@/components/ui/switch";
|
||||
import { useCreateWidget, useUpdateWidget } from "@/lib/api/hooks";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Check, ChevronsUpDown, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const POPULAR_TIMEZONES = [
|
||||
"America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles",
|
||||
"America/Anchorage", "Pacific/Honolulu", "America/Sao_Paulo", "America/Argentina/Buenos_Aires",
|
||||
"Europe/London", "Europe/Paris", "Europe/Berlin", "Europe/Prague", "Europe/Moscow",
|
||||
"Asia/Dubai", "Asia/Kolkata", "Asia/Bangkok", "Asia/Shanghai", "Asia/Tokyo", "Asia/Seoul",
|
||||
"Australia/Sydney", "Australia/Melbourne", "Pacific/Auckland", "UTC",
|
||||
];
|
||||
|
||||
const WIDGET_TYPES = ["clock", "image", "pihole", "memos", "immich"] as const;
|
||||
|
||||
interface WidgetFormProps {
|
||||
widget?: WidgetInstance | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function WidgetForm({ widget, open, onOpenChange }: WidgetFormProps) {
|
||||
const isEdit = !!widget;
|
||||
const createMut = useCreateWidget();
|
||||
const updateMut = useUpdateWidget();
|
||||
|
||||
const [type, setType] = useState<string>(widget?.type || "clock");
|
||||
const [title, setTitle] = useState(widget?.title || "");
|
||||
const [enabled, setEnabled] = useState(widget?.enabled ?? true);
|
||||
const [selectedTzs, setSelectedTzs] = useState<string[]>(
|
||||
(widget?.config?.timezones as string[]) || [],
|
||||
);
|
||||
const [tzPopoverOpen, setTzPopoverOpen] = useState(false);
|
||||
const [imageUrl, setImageUrl] = useState((widget?.config?.imageUrl as string) || "");
|
||||
const [linkUrl, setLinkUrl] = useState((widget?.config?.linkUrl as string) || "");
|
||||
const [piholeBaseUrl, setPiholeBaseUrl] = useState((widget?.config?.baseUrl as string) || "");
|
||||
const [piholeApiToken, setPiholeApiToken] = useState((widget?.config?.apiToken as string) || "");
|
||||
const [memosBaseUrl, setMemosBaseUrl] = useState((widget?.config?.baseUrl as string) || "");
|
||||
const [memosApiToken, setMemosApiToken] = useState((widget?.config?.apiToken as string) || "");
|
||||
const [memosPageSize, setMemosPageSize] = useState(String((widget?.config?.pageSize as number) || 5));
|
||||
const [immichBaseUrl, setImmichBaseUrl] = useState((widget?.config?.baseUrl as string) || "");
|
||||
const [immichApiKey, setImmichApiKey] = useState((widget?.config?.apiKey as string) || "");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const buildConfig = (): Record<string, unknown> => {
|
||||
switch (type) {
|
||||
case "clock":
|
||||
return { timezones: selectedTzs };
|
||||
case "image":
|
||||
return { imageUrl, linkUrl: linkUrl || null };
|
||||
case "pihole":
|
||||
return { baseUrl: piholeBaseUrl, apiToken: piholeApiToken };
|
||||
case "memos":
|
||||
return { baseUrl: memosBaseUrl, apiToken: memosApiToken, pageSize: parseInt(memosPageSize) || 5 };
|
||||
case "immich":
|
||||
return { baseUrl: immichBaseUrl, apiKey: immichApiKey };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim()) { setError("Title is required"); return; }
|
||||
if ((type === "pihole" || type === "memos") && !piholeBaseUrl && !memosBaseUrl) {
|
||||
setError("Base URL is required");
|
||||
return;
|
||||
}
|
||||
if (type === "immich" && !immichBaseUrl) {
|
||||
setError("Base URL is required");
|
||||
return;
|
||||
}
|
||||
if (type === "image" && !imageUrl) { setError("Image URL is required"); return; }
|
||||
|
||||
const body: WidgetRequest = {
|
||||
type: type as WidgetRequest["type"],
|
||||
title: title.trim(),
|
||||
enabled,
|
||||
config: buildConfig() as WidgetRequest["config"],
|
||||
};
|
||||
|
||||
try {
|
||||
if (isEdit && widget) {
|
||||
await updateMut.mutateAsync({ id: widget.id, ...body });
|
||||
} else {
|
||||
await createMut.mutateAsync(body);
|
||||
}
|
||||
onOpenChange(false);
|
||||
setError("");
|
||||
} catch (err) {
|
||||
setError(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 Widget" : "Add Widget"}</DialogTitle>
|
||||
<DialogDescription>{isEdit ? "Update widget settings" : "Add a new widget to your dashboard"}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Type</Label>
|
||||
<Select value={type} onValueChange={setType} disabled={isEdit}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{WIDGET_TYPES.map((t) => (
|
||||
<SelectItem key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="widget-title">Title</Label>
|
||||
<Input id="widget-title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="My Widget" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
<Label>Enabled</Label>
|
||||
</div>
|
||||
|
||||
{type === "clock" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Timezones</Label>
|
||||
<div className="flex flex-wrap gap-1 mb-1">
|
||||
{selectedTzs.map((tz) => (
|
||||
<Badge key={tz} variant="secondary" className="gap-1 text-xs">
|
||||
{tz.split("/").pop()?.replace("_", " ")}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-0.5 rounded-full hover:bg-foreground/10"
|
||||
onClick={() => setSelectedTzs((prev) => prev.filter((t) => t !== tz))}
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<Popover open={tzPopoverOpen} onOpenChange={setTzPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" type="button" className="justify-between text-xs font-normal">
|
||||
Add timezone…
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search timezone…" />
|
||||
<CommandList>
|
||||
<CommandEmpty>No timezone found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{POPULAR_TIMEZONES.filter((tz) => !selectedTzs.includes(tz)).map((tz) => (
|
||||
<CommandItem
|
||||
key={tz}
|
||||
value={tz}
|
||||
onSelect={() => {
|
||||
setSelectedTzs((prev) => [...prev, tz]);
|
||||
setTzPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check className={cn("mr-2 h-3 w-3", selectedTzs.includes(tz) ? "opacity-100" : "opacity-0")} />
|
||||
{tz}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
{type === "image" && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Image URL</Label>
|
||||
<Input value={imageUrl} onChange={(e) => setImageUrl(e.target.value)} placeholder="https://example.com/image.jpg" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Link URL (optional)</Label>
|
||||
<Input value={linkUrl} onChange={(e) => setLinkUrl(e.target.value)} placeholder="https://example.com" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{type === "pihole" && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Pi-hole Base URL</Label>
|
||||
<Input value={piholeBaseUrl} onChange={(e) => setPiholeBaseUrl(e.target.value)} placeholder="http://pihole.local" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>API Token</Label>
|
||||
<Input type="password" value={piholeApiToken} onChange={(e) => setPiholeApiToken(e.target.value)} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{type === "memos" && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Memos Base URL</Label>
|
||||
<Input value={memosBaseUrl} onChange={(e) => setMemosBaseUrl(e.target.value)} placeholder="http://memos.local:5230" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>API Token</Label>
|
||||
<Input type="password" value={memosApiToken} onChange={(e) => setMemosApiToken(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Page Size</Label>
|
||||
<Input type="number" value={memosPageSize} onChange={(e) => setMemosPageSize(e.target.value)} min={1} max={20} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{type === "immich" && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Immich Base URL</Label>
|
||||
<Input value={immichBaseUrl} onChange={(e) => setImmichBaseUrl(e.target.value)} placeholder="http://immich.local:2283" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>API Key</Label>
|
||||
<Input type="password" value={immichApiKey} onChange={(e) => setImmichApiKey(e.target.value)} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{error && <span className="text-xs text-destructive">{error}</span>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} disabled={createMut.isPending || updateMut.isPending}>
|
||||
{isEdit ? "Save" : "Add Widget"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user