refactor(frontend): simplify theme system and unify UI components

Remove the "casaos" theme in favor of a unified design system. This involves cleaning up conditional styling across components, simplifying the theme toggle, and updating the global CSS variables to a more consistent dark/light mode implementation.

- Remove `casaos` theme from `themes.ts` and `ThemeToggle`
- Refactor `globals.css` to use a single dark mode definition
- Simplify component styling by removing `isCasaOS` conditional logic
- Update UI components (`Card`, `Badge`, `WidgetCard`, etc.) to use standard design tokens
- Update E2E smoke tests to reflect theme changes
This commit is contained in:
Tomas Dvorak
2026-05-04 18:32:35 +02:00
parent eaa9bfda90
commit 9e7acc868d
15 changed files with 154 additions and 447 deletions
+17 -48
View File
@@ -1,6 +1,6 @@
@import "tailwindcss"; @import "tailwindcss";
@custom-variant dark (&:where([data-theme="dark"], [data-theme="casaos"])); @custom-variant dark (&:where([data-theme="dark"]));
/* ── Light (Vercel-inspired) ── */ /* ── Light (Vercel-inspired) ── */
:root, :root,
@@ -21,10 +21,11 @@
--color-accent-foreground: #171717; --color-accent-foreground: #171717;
--color-destructive: #ef4444; --color-destructive: #ef4444;
--color-destructive-foreground: #ffffff; --color-destructive-foreground: #ffffff;
--color-border: rgba(0, 0, 0, 0.08); --color-border: #e5e5e5;
--color-ring: #0072f5; --color-ring: #0072f5;
--color-signal: #ff5b4f; --color-signal: #ff5b4f;
--color-input: rgba(0, 0, 0, 0.08); --color-input: #e5e5e5;
--color-overlay: #f5f5f5;
--radius: 0.5rem; --radius: 0.5rem;
--font-geist-sans: "Geist", "Arial", "Apple Color Emoji", "Segoe UI Emoji", sans-serif; --font-geist-sans: "Geist", "Arial", "Apple Color Emoji", "Segoe UI Emoji", sans-serif;
--font-geist-mono: "Geist Mono", "ui-monospace", "SFMono-Regular", "Roboto Mono", monospace; --font-geist-mono: "Geist Mono", "ui-monospace", "SFMono-Regular", "Roboto Mono", monospace;
@@ -32,57 +33,29 @@
/* ── Dark (Rich warm dark — not pure black) ── */ /* ── Dark (Rich warm dark — not pure black) ── */
[data-theme="dark"] { [data-theme="dark"] {
--color-background: #1b1b1b; --color-background: #0d0d0d;
--color-foreground: #ececec; --color-foreground: #ececec;
--color-card: #222222; --color-card: #141414;
--color-card-foreground: #ececec; --color-card-foreground: #ececec;
--color-popover: #262626; --color-popover: #1a1a1a;
--color-popover-foreground: #ececec; --color-popover-foreground: #ececec;
--color-primary: #ececec; --color-primary: #ececec;
--color-primary-foreground: #1b1b1b; --color-primary-foreground: #0d0d0d;
--color-secondary: #2a2a2a; --color-secondary: #1a1a1a;
--color-secondary-foreground: #ececec; --color-secondary-foreground: #ececec;
--color-muted: #2a2a2a; --color-muted: #1a1a1a;
--color-muted-foreground: #888888; --color-muted-foreground: #888888;
--color-accent: #2a2a2a; --color-accent: #1a1a1a;
--color-accent-foreground: #ececec; --color-accent-foreground: #ececec;
--color-destructive: #f43f5e; --color-destructive: #f43f5e;
--color-destructive-foreground: #ececec; --color-destructive-foreground: #ececec;
--color-border: #333333; --color-border: #262626;
--color-ring: #3b82f6; --color-ring: #3b82f6;
--color-signal: #f43f5e; --color-signal: #f43f5e;
--color-input: #333333; --color-input: #262626;
--color-overlay: #050505;
} }
/* ── CasaOS (Colorful dark) ── */
[data-theme="casaos"] {
--color-background: #1b1b2e;
--color-foreground: #f1f5f9;
--color-card: #22223a;
--color-card-foreground: #f1f5f9;
--color-popover: #26264a;
--color-popover-foreground: #f1f5f9;
--color-primary: #60a5fa;
--color-primary-foreground: #1b1b2e;
--color-secondary: #2a2a4a;
--color-secondary-foreground: #f1f5f9;
--color-muted: #2a2a4a;
--color-muted-foreground: #94a3b8;
--color-accent: #2a2a4a;
--color-accent-foreground: #60a5fa;
--color-destructive: #f43f5e;
--color-destructive-foreground: #f1f5f9;
--color-border: #333355;
--color-ring: #60a5fa;
--color-signal: #f43f5e;
--color-input: #333355;
}
/* ── CasaOS background gradient ── */
[data-theme="casaos"] body {
background: #1b1b2e;
background-attachment: fixed;
}
/* ── Base ── */ /* ── Base ── */
* { * {
@@ -152,10 +125,6 @@ body {
transform: translateY(-2px); transform: translateY(-2px);
} }
/* ── CasaOS card hover ── */
[data-theme="casaos"] .service-card:hover {
transform: translateY(-4px);
}
/* ── Drag overlay ── */ /* ── Drag overlay ── */
.drag-overlay { .drag-overlay {
@@ -212,14 +181,14 @@ body {
/* ── Colorful badge variants ── */ /* ── Colorful badge variants ── */
.badge-local { .badge-local {
background: rgba(16, 185, 129, 0.15); background: #0f291e;
color: #34d399; color: #34d399;
} }
.badge-external { .badge-external {
background: rgba(96, 165, 250, 0.15); background: #162038;
color: #60a5fa; color: #60a5fa;
} }
.badge-custom { .badge-custom {
background: rgba(139, 92, 246, 0.15); background: #231a38;
color: #a78bfa; color: #a78bfa;
} }
+68 -244
View File
@@ -11,6 +11,7 @@ import { GroupForm } from "@/components/groups/group-form";
import { WidgetCard } from "@/components/widgets/widget-card"; import { WidgetCard } from "@/components/widgets/widget-card";
import { WidgetForm } from "@/components/widgets/widget-form"; import { WidgetForm } from "@/components/widgets/widget-form";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Plus, Loader2, AlertCircle, LayoutGrid, List, Pencil, Trash2, GripVertical } from "lucide-react"; import { Plus, Loader2, AlertCircle, LayoutGrid, List, Pencil, Trash2, GripVertical } from "lucide-react";
import { import {
DndContext, DndContext,
@@ -27,74 +28,13 @@ import {
} from "@dnd-kit/core"; } from "@dnd-kit/core";
import { import {
SortableContext, SortableContext,
verticalListSortingStrategy,
rectSortingStrategy, rectSortingStrategy,
useSortable, useSortable,
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/* ---------- Sortable wrappers ---------- */ /* ---------- Sortable wrapper for widgets only ---------- */
function SortableGroup({
group,
onEditService,
onDeleteService,
onEditGroup,
}: {
group: Group;
onEditService: (s: Service) => void;
onDeleteService: (id: string) => void;
onEditGroup: (g: Group) => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: group.id,
data: { type: "group" },
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
};
return (
<div ref={setNodeRef} style={style} {...attributes}>
<GroupSection
group={group}
onEditService={onEditService}
onDeleteService={onDeleteService}
onEditGroup={onEditGroup}
dragHandleProps={listeners}
/>
</div>
);
}
function SortableService({
service,
onEdit,
onDelete,
}: {
service: Service;
onEdit: (s: Service) => void;
onDelete: (id: string) => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: service.id,
data: { type: "service", groupId: service.groupId },
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
};
return (
<div ref={setNodeRef} style={style} {...attributes}>
<ServiceCard service={service} onEdit={onEdit} onDelete={onDelete} dragHandleProps={listeners} isDragging={isDragging} />
</div>
);
}
function SortableWidget({ function SortableWidget({
widget, widget,
@@ -173,7 +113,7 @@ function ServiceListItem({
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-lg hover:bg-accent" onClick={() => onEdit(service)}> <Button variant="ghost" size="icon" className="h-7 w-7 rounded-lg hover:bg-accent" onClick={() => onEdit(service)}>
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-lg text-destructive hover:bg-destructive/10" onClick={() => onDelete(service.id)}> <Button variant="ghost" size="icon" className="h-7 w-7 rounded-lg text-destructive hover:bg-accent" onClick={() => onDelete(service.id)}>
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -181,42 +121,11 @@ function ServiceListItem({
); );
} }
/* ---------- Drag Overlay ---------- */ /* ---------- Drag Overlay (widgets only) ---------- */
function DashboardDragOverlay({ activeId, dashboard }: { activeId: string; dashboard: Dashboard }) { function DashboardDragOverlay({ activeId, dashboard }: { activeId: string; dashboard: Dashboard }) {
const allServices = [
...dashboard.ungroupedServices,
...dashboard.groups.flatMap((g) => g.services),
];
const service = allServices.find((s) => s.id === activeId);
const group = dashboard.groups.find((g) => g.id === activeId);
const widget = dashboard.widgets.find((w) => w.id === activeId); const widget = dashboard.widgets.find((w) => w.id === activeId);
if (service) {
return (
<div className="drag-overlay flex aspect-square w-28 flex-col items-center justify-center gap-2 rounded-2xl bg-card border border-ring/50 p-3 shadow-2xl">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-secondary to-accent font-mono text-sm font-bold text-secondary-foreground">
{service.name.slice(0, 2).toUpperCase()}
</div>
<span className="text-xs font-semibold text-center truncate w-full">{service.name}</span>
</div>
);
}
if (group) {
return (
<div className="drag-overlay flex w-64 items-center gap-3 rounded-xl bg-card border border-ring/50 px-4 py-3 shadow-2xl">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent">
<GripVertical className="h-4 w-4 text-accent-foreground" />
</div>
<div>
<span className="text-sm font-semibold">{group.name}</span>
<span className="text-xs text-muted-foreground ml-2">{group.services.length} apps</span>
</div>
</div>
);
}
if (widget) { if (widget) {
return ( return (
<div className="drag-overlay flex w-56 items-center gap-3 rounded-xl bg-card border border-ring/50 px-4 py-3 shadow-2xl"> <div className="drag-overlay flex w-56 items-center gap-3 rounded-xl bg-card border border-ring/50 px-4 py-3 shadow-2xl">
@@ -231,7 +140,7 @@ function DashboardDragOverlay({ activeId, dashboard }: { activeId: string; dashb
); );
} }
return <div className="drag-overlay rounded-xl bg-card p-4 shadow-2xl border border-ring/50">Moving</div>; return null;
} }
/* ---------- Main Dashboard ---------- */ /* ---------- Main Dashboard ---------- */
@@ -273,95 +182,13 @@ export default function DashboardPage() {
const activeIdStr = String(active.id); const activeIdStr = String(active.id);
const overIdStr = String(over.id); const overIdStr = String(over.id);
const allServiceIds = [
...dashboard.ungroupedServices.map((s) => s.id),
...dashboard.groups.flatMap((g) => g.services.map((s) => s.id)),
];
const groupIds = dashboard.groups.map((g) => g.id); const groupIds = dashboard.groups.map((g) => g.id);
const widgetIds = dashboard.widgets.map((w) => w.id); const widgetIds = dashboard.widgets.map((w) => w.id);
const isActiveService = allServiceIds.includes(activeIdStr);
const isOverService = allServiceIds.includes(overIdStr);
const isActiveGroup = groupIds.includes(activeIdStr);
const isOverGroup = groupIds.includes(overIdStr);
const isActiveWidget = widgetIds.includes(activeIdStr); const isActiveWidget = widgetIds.includes(activeIdStr);
const isOverWidget = widgetIds.includes(overIdStr); const isOverWidget = widgetIds.includes(overIdStr);
// Service → Service (reorder / cross-group) // Widget reorder only
if (isActiveService && isOverService) {
const findServiceLocation = (sid: string): { groupId: string | null; index: number } => {
const ungroupedIdx = dashboard.ungroupedServices.findIndex((s) => s.id === sid);
if (ungroupedIdx !== -1) return { groupId: null, index: ungroupedIdx };
for (const g of dashboard.groups) {
const idx = g.services.findIndex((s) => s.id === sid);
if (idx !== -1) return { groupId: g.id, index: idx };
}
return { groupId: null, index: -1 };
};
const activeLoc = findServiceLocation(activeIdStr);
const overLoc = findServiceLocation(overIdStr);
const groupServices: Record<string, string[]> = {};
for (const g of dashboard.groups) {
const ids = [...g.services.map((s) => s.id)];
if (activeLoc.groupId === g.id) ids.splice(activeLoc.index, 1);
if (overLoc.groupId === g.id) {
const insertIdx = activeLoc.groupId === g.id && activeLoc.index < overLoc.index ? overLoc.index : overLoc.index;
ids.splice(insertIdx, 0, activeIdStr);
}
groupServices[g.id] = ids;
}
const ungroupedIds = [...dashboard.ungroupedServices.map((s) => s.id)];
if (activeLoc.groupId === null) ungroupedIds.splice(activeLoc.index, 1);
if (overLoc.groupId === null) {
const insertIdx = activeLoc.groupId === null && activeLoc.index < overLoc.index ? overLoc.index : overLoc.index;
ungroupedIds.splice(insertIdx, 0, activeIdStr);
}
if (activeLoc.groupId !== null && overLoc.groupId === null) {
ungroupedIds.splice(overLoc.index, 0, activeIdStr);
}
updateLayout.mutate({ groupIds, widgetIds, ungroupedServiceIds: ungroupedIds, groupServices });
return;
}
// Service → Group header (move into group)
if (isActiveService && isOverGroup) {
const groupServices: Record<string, string[]> = {};
const ungroupedIds = [...dashboard.ungroupedServices.map((s) => s.id)];
for (const g of dashboard.groups) {
const ids = g.services.map((s) => s.id);
const idx = ids.indexOf(activeIdStr);
if (idx !== -1) ids.splice(idx, 1);
if (g.id === overIdStr) ids.push(activeIdStr);
groupServices[g.id] = ids;
}
const uIdx = ungroupedIds.indexOf(activeIdStr);
if (uIdx !== -1) ungroupedIds.splice(uIdx, 1);
updateLayout.mutate({ groupIds, widgetIds, ungroupedServiceIds: ungroupedIds, groupServices });
return;
}
// Group reorder
if (isActiveGroup && isOverGroup) {
const newGroupIds = [...groupIds];
const fromIdx = newGroupIds.indexOf(activeIdStr);
const toIdx = newGroupIds.indexOf(overIdStr);
if (fromIdx !== -1 && toIdx !== -1) {
const [moved] = newGroupIds.splice(fromIdx, 1);
newGroupIds.splice(toIdx, 0, moved);
const groupServices: Record<string, string[]> = {};
for (const g of dashboard.groups) groupServices[g.id] = g.services.map((s) => s.id);
updateLayout.mutate({ groupIds: newGroupIds, widgetIds, ungroupedServiceIds: dashboard.ungroupedServices.map((s) => s.id), groupServices });
}
return;
}
// Widget reorder
if (isActiveWidget && isOverWidget) { if (isActiveWidget && isOverWidget) {
const newWidgetIds = [...widgetIds]; const newWidgetIds = [...widgetIds];
const fromIdx = newWidgetIds.indexOf(activeIdStr); const fromIdx = newWidgetIds.indexOf(activeIdStr);
@@ -405,9 +232,9 @@ export default function DashboardPage() {
if (error) { if (error) {
return ( return (
<div className="flex h-screen flex-col bg-background"> <div className="flex h-screen flex-col bg-background">
<div className="h-14 border-b border-border/50" /> <div className="h-14 border-b border-border" />
<div className="flex flex-1 flex-col items-center justify-center gap-4"> <div className="flex flex-1 flex-col items-center justify-center gap-4">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-destructive/10"> <div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-muted">
<AlertCircle className="h-6 w-6 text-destructive" /> <AlertCircle className="h-6 w-6 text-destructive" />
</div> </div>
<div className="text-center"> <div className="text-center">
@@ -434,7 +261,7 @@ export default function DashboardPage() {
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-6"> <main className="mx-auto w-full max-w-6xl flex-1 px-4 py-6">
{isEmpty ? ( {isEmpty ? (
<div className="flex flex-col items-center justify-center gap-6 py-32"> <div className="flex flex-col items-center justify-center gap-6 py-32">
<div className="flex h-20 w-20 items-center justify-center rounded-[24px] bg-gradient-to-br from-secondary to-accent border border-border/50 shadow-border-card"> <div className="flex h-20 w-20 items-center justify-center rounded-[24px] bg-gradient-to-br from-secondary to-accent border border-border shadow-border-card">
<LayoutGrid className="h-8 w-8 text-muted-foreground" /> <LayoutGrid className="h-8 w-8 text-muted-foreground" />
</div> </div>
<div className="text-center"> <div className="text-center">
@@ -459,42 +286,44 @@ export default function DashboardPage() {
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
{/* Widgets strip */} {/* Widgets section */}
<section className="mb-8"> <Card className="mb-6">
<div className="mb-4 flex items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between pb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-4 w-0.5 rounded-full bg-ring" /> <div className="h-4 w-0.5 rounded-full bg-ring" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Widgets</span> <CardTitle className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Widgets</CardTitle>
</div> </div>
<Button variant="ghost" size="sm" onClick={openAddWidget} className="gap-1.5 text-xs rounded-lg hover:bg-accent"> <Button variant="ghost" size="sm" onClick={openAddWidget} className="gap-1.5 text-xs rounded-lg hover:bg-accent">
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Add Widget</span> <span className="hidden sm:inline">Add Widget</span>
</Button> </Button>
</div> </CardHeader>
{widgets.length > 0 ? ( <CardContent>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> {widgets.length > 0 ? (
<SortableContext items={widgets.map((w) => w.id)} strategy={rectSortingStrategy}> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{widgets.map((w) => ( <SortableContext items={widgets.map((w) => w.id)} strategy={rectSortingStrategy}>
<SortableWidget key={w.id} widget={w} onEdit={handleEditWidget} onDelete={handleDeleteWidget} /> {widgets.map((w) => (
))} <SortableWidget key={w.id} widget={w} onEdit={handleEditWidget} onDelete={handleDeleteWidget} />
</SortableContext> ))}
</div> </SortableContext>
) : ( </div>
<button ) : (
onClick={openAddWidget} <button
className="flex w-full items-center justify-center gap-2 rounded-2xl border border-dashed border-border bg-card p-6 text-sm text-muted-foreground transition-all hover:border-ring/40 hover:bg-accent hover:text-foreground" onClick={openAddWidget}
> className="flex w-full items-center justify-center gap-2 rounded-xl border border-dashed border-border bg-secondary/50 p-6 text-sm text-muted-foreground transition-all hover:border-ring/40 hover:bg-accent hover:text-foreground"
<Plus className="h-4 w-4" /> Add your first widget >
</button> <Plus className="h-4 w-4" /> Add your first widget
)} </button>
</section> )}
</CardContent>
</Card>
{/* Apps section */} {/* Apps section */}
<section className="mb-4"> <Card>
<div className="mb-4 flex items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between pb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-4 w-0.5 rounded-full bg-ring" /> <div className="h-4 w-0.5 rounded-full bg-ring" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Apps</span> <CardTitle className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Apps</CardTitle>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<div className="flex items-center rounded-lg border border-border overflow-hidden mr-1 bg-card"> <div className="flex items-center rounded-lg border border-border overflow-hidden mr-1 bg-card">
@@ -505,7 +334,7 @@ export default function DashboardPage() {
> >
<LayoutGrid className="h-3.5 w-3.5" /> <LayoutGrid className="h-3.5 w-3.5" />
</button> </button>
<div className="w-px h-3.5 bg-border/50" /> <div className="w-px h-3.5 bg-border" />
<button <button
onClick={() => setViewMode("list")} onClick={() => setViewMode("list")}
className={cn("px-2.5 py-1.5 text-xs transition-colors rounded-r-lg", viewMode === "list" ? "bg-accent text-accent-foreground" : "text-muted-foreground hover:text-foreground")} className={cn("px-2.5 py-1.5 text-xs transition-colors rounded-r-lg", viewMode === "list" ? "bg-accent text-accent-foreground" : "text-muted-foreground hover:text-foreground")}
@@ -523,12 +352,11 @@ export default function DashboardPage() {
<span className="hidden sm:inline">App</span> <span className="hidden sm:inline">App</span>
</Button> </Button>
</div> </div>
</div> </CardHeader>
<CardContent className="space-y-4">
{/* Groups */} {/* Groups */}
<SortableContext items={groups.map((g) => g.id)} strategy={verticalListSortingStrategy}>
{groups.map((g) => ( {groups.map((g) => (
<SortableGroup <GroupSection
key={g.id} key={g.id}
group={g} group={g}
onEditService={handleEditService} onEditService={handleEditService}
@@ -536,23 +364,21 @@ export default function DashboardPage() {
onEditGroup={handleEditGroup} onEditGroup={handleEditGroup}
/> />
))} ))}
</SortableContext>
{/* Ungrouped services */} {/* Ungrouped services */}
{ungrouped.length > 0 && ( {ungrouped.length > 0 && (
<div className="mb-2"> <div>
{groups.length > 0 && ( {groups.length > 0 && (
<div className="mb-4 flex items-center gap-2"> <div className="mb-3 flex items-center gap-2">
<div className="h-4 w-0.5 rounded-full bg-ring" /> <div className="h-4 w-0.5 rounded-full bg-ring" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Ungrouped</span> <span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Ungrouped</span>
<span className="text-xs text-muted-foreground font-mono">{ungrouped.length}</span> <span className="text-xs text-muted-foreground font-mono">{ungrouped.length}</span>
</div> </div>
)} )}
<SortableContext items={ungrouped.map((s) => s.id)} strategy={rectSortingStrategy}>
{viewMode === "grid" ? ( {viewMode === "grid" ? (
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8"> <div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8">
{ungrouped.map((s) => ( {ungrouped.map((s) => (
<SortableService key={s.id} service={s} onEdit={handleEditService} onDelete={handleDeleteService} /> <ServiceCard key={s.id} service={s} onEdit={handleEditService} onDelete={handleDeleteService} />
))} ))}
<AddAppTile onClick={openAddService} /> <AddAppTile onClick={openAddService} />
</div> </div>
@@ -563,27 +389,25 @@ export default function DashboardPage() {
))} ))}
</div> </div>
)} )}
</SortableContext> </div>
</div> )}
)}
{/* In-grid add tile when no ungrouped but groups exist */} {/* Add tile when no ungrouped but groups exist */}
{ungrouped.length === 0 && groups.length > 0 && ( {ungrouped.length === 0 && groups.length > 0 && (
<div className="mt-2">
<AddAppTile onClick={openAddService} /> <AddAppTile onClick={openAddService} />
</div> )}
)}
{/* No apps at all - show empty state within apps section */} {/* No apps at all - show empty state within apps section */}
{groups.length === 0 && ungrouped.length === 0 && ( {groups.length === 0 && ungrouped.length === 0 && (
<button <button
onClick={openAddService} onClick={openAddService}
className="flex w-full items-center justify-center gap-2 rounded-2xl border border-dashed border-border bg-card p-8 text-sm text-muted-foreground transition-all hover:border-ring/40 hover:bg-accent hover:text-foreground" className="flex w-full items-center justify-center gap-2 rounded-xl border border-dashed border-border bg-secondary/50 p-8 text-sm text-muted-foreground transition-all hover:border-ring/40 hover:bg-accent hover:text-foreground"
> >
<Plus className="h-4 w-4" /> Add your first app <Plus className="h-4 w-4" /> Add your first app
</button> </button>
)} )}
</section> </CardContent>
</Card>
<DragOverlay> <DragOverlay>
{activeId && dashboard ? ( {activeId && dashboard ? (
+11 -26
View File
@@ -4,27 +4,25 @@ import type { Group, Service } from "@/lib/api/schema";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { ServiceCard } from "@/components/services/service-card"; import { ServiceCard } from "@/components/services/service-card";
import { ChevronDown, MoreVertical, Pencil, Trash2, GripVertical, FolderOpen } from "lucide-react"; import { ChevronDown, MoreVertical, Pencil, Trash2, FolderOpen } from "lucide-react";
import { useUpdateGroup, useDeleteGroup } from "@/lib/api/hooks"; import { useUpdateGroup, useDeleteGroup } from "@/lib/api/hooks";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useState } from "react"; import { useState } from "react";
import { useTheme } from "@/components/providers";
interface GroupSectionProps { interface GroupSectionProps {
group: Group; group: Group;
onEditService: (s: Service) => void; onEditService: (s: Service) => void;
onDeleteService: (id: string) => void; onDeleteService: (id: string) => void;
onEditGroup: (g: Group) => void; onEditGroup: (g: Group) => void;
dragHandleProps?: React.HTMLAttributes<HTMLDivElement>;
} }
export function GroupSection({ group, onEditService, onDeleteService, onEditGroup, dragHandleProps }: GroupSectionProps) { export function GroupSection({ group, onEditService, onDeleteService, onEditGroup }: GroupSectionProps) {
const updateGroup = useUpdateGroup(); const updateGroup = useUpdateGroup();
const deleteGroup = useDeleteGroup(); const deleteGroup = useDeleteGroup();
const [open, setOpen] = useState(!group.collapsed); const [open, setOpen] = useState(!group.collapsed);
const { theme } = useTheme();
const isCasaOS = theme === "casaos";
const handleToggle = () => { const handleToggle = () => {
const next = !open; const next = !open;
@@ -42,28 +40,16 @@ export function GroupSection({ group, onEditService, onDeleteService, onEditGrou
return ( return (
<Collapsible open={open} onOpenChange={setOpen}> <Collapsible open={open} onOpenChange={setOpen}>
<div className={cn("mb-5 rounded-2xl group/group", isCasaOS && "bg-card border border-border")}> <Card className="mb-4 overflow-hidden">
{/* Group header */} {/* Group header */}
<div className="flex items-center gap-2 px-3 py-2.5"> <div className="flex items-center gap-2 px-4 py-3">
{dragHandleProps && (
<div
{...dragHandleProps}
className="cursor-grab rounded-md p-1 opacity-0 transition-opacity hover:bg-accent group-hover/group:opacity-60"
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</div>
)}
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<button <button
className="flex flex-1 items-center gap-2.5 group/title min-w-0" className="flex flex-1 items-center gap-2.5 group/title min-w-0"
onClick={handleToggle} onClick={handleToggle}
> >
<div className={cn( <div className="flex h-7 w-7 items-center justify-center rounded-lg transition-colors bg-accent">
"flex h-7 w-7 items-center justify-center rounded-lg transition-colors", <FolderOpen className="h-3.5 w-3.5 text-accent-foreground" />
isCasaOS ? "bg-white/10" : "bg-accent"
)}>
<FolderOpen className={cn("h-3.5 w-3.5", isCasaOS ? "text-blue-300" : "text-accent-foreground")} />
</div> </div>
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-semibold truncate">{group.name}</span> <span className="text-sm font-semibold truncate">{group.name}</span>
@@ -94,12 +80,11 @@ export function GroupSection({ group, onEditService, onDeleteService, onEditGrou
</DropdownMenu> </DropdownMenu>
</div> </div>
{/* Divider */} <Separator />
<div className={cn("mx-3 h-px", isCasaOS ? "bg-white/5" : "bg-border/40")} />
{/* Services grid */} {/* Services grid */}
<CollapsibleContent> <CollapsibleContent>
<div className="p-3 pt-2"> <div className="p-4">
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8"> <div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8">
{group.services.map((s) => ( {group.services.map((s) => (
<ServiceCard key={s.id} service={s} onEdit={onEditService} onDelete={onDeleteService} /> <ServiceCard key={s.id} service={s} onEdit={onEditService} onDelete={onDeleteService} />
@@ -107,7 +92,7 @@ export function GroupSection({ group, onEditService, onDeleteService, onEditGrou
</div> </div>
</div> </div>
</CollapsibleContent> </CollapsibleContent>
</div> </Card>
</Collapsible> </Collapsible>
); );
} }
+8 -42
View File
@@ -7,9 +7,8 @@ import { Badge } from "@/components/ui/badge";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from "@/components/ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { MoreVertical, ExternalLink, Pencil, Trash2, GripVertical, Globe, Home, Settings } from "lucide-react"; import { MoreVertical, ExternalLink, Pencil, Trash2, Globe, Home, Settings } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTheme } from "@/components/providers";
function getInitials(name: string) { function getInitials(name: string) {
const words = name.trim().split(/\s+/); const words = name.trim().split(/\s+/);
@@ -130,18 +129,12 @@ export function ServiceCard({
service, service,
onEdit, onEdit,
onDelete, onDelete,
isDragging = false,
dragHandleProps,
}: { }: {
service: Service; service: Service;
onEdit: (s: Service) => void; onEdit: (s: Service) => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
isDragging?: boolean;
dragHandleProps?: React.HTMLAttributes<HTMLDivElement>;
}) { }) {
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const { theme } = useTheme();
const isCasaOS = theme === "casaos";
const handleClick = () => { const handleClick = () => {
if (service.urls.length === 1) { if (service.urls.length === 1) {
@@ -160,40 +153,22 @@ export function ServiceCard({
<Card <Card
className={cn( className={cn(
"service-card group relative cursor-pointer overflow-hidden", "service-card group relative cursor-pointer overflow-hidden",
isCasaOS "aspect-square rounded-2xl border border-border bg-card shadow-[0px_0px_0px_1px_var(--color-border)] hover:bg-accent hover:shadow-border-hover",
? "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} onClick={handleClick}
> >
{/* Gradient accent line at top */} {/* Gradient accent line at top */}
<div className={cn( <div className="absolute top-0 left-0 right-0 h-[2px] opacity-0 group-hover:opacity-100 transition-opacity bg-ring" />
"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"> <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 */} {/* Icon container */}
<div className={cn( <div className="relative flex items-center justify-center transition-transform duration-300 group-hover:scale-110 h-12 w-12">
"relative flex items-center justify-center transition-transform duration-300 group-hover:scale-110",
isCasaOS ? "h-[52px] w-[52px]" : "h-12 w-12"
)}>
{iconSrc ? ( {iconSrc ? (
<img <img
src={iconSrc} src={iconSrc}
alt={service.name} alt={service.name}
className={cn("h-full w-full object-contain drop-shadow-lg", isCasaOS ? "rounded-2xl" : "rounded-xl")} className="h-full w-full object-contain drop-shadow-lg rounded-xl"
onError={(e) => { onError={(e) => {
(e.target as HTMLImageElement).style.display = "none"; (e.target as HTMLImageElement).style.display = "none";
(e.target as HTMLImageElement).nextElementSibling?.classList.remove("hidden"); (e.target as HTMLImageElement).nextElementSibling?.classList.remove("hidden");
@@ -202,10 +177,7 @@ export function ServiceCard({
) : null} ) : null}
<div <div
className={cn( className={cn(
"flex h-full w-full items-center justify-center rounded-xl font-mono font-bold text-secondary-foreground", "flex h-full w-full items-center justify-center rounded-xl font-mono font-bold text-secondary-foreground bg-secondary text-sm",
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", iconSrc && "hidden",
)} )}
> >
@@ -215,10 +187,7 @@ export function ServiceCard({
</div> </div>
{/* App name */} {/* App name */}
<span className={cn( <span className="max-w-full truncate text-center font-semibold leading-tight text-xs text-foreground">
"max-w-full truncate text-center font-semibold leading-tight",
isCasaOS ? "text-sm text-white/90" : "text-xs text-foreground"
)}>
{service.name} {service.name}
</span> </span>
@@ -257,10 +226,7 @@ export function ServiceCard({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className={cn( className="h-7 w-7 rounded-lg hover:bg-accent"
"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" /> <MoreVertical className="h-3.5 w-3.5" />
</Button> </Button>
+18 -37
View File
@@ -1,48 +1,29 @@
"use client"; "use client";
import { useTheme } from "@/components/providers"; import { useTheme } from "@/components/providers";
import { themeLabels, type Theme } from "@/lib/theme/themes";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { Sun, Moon } from "lucide-react";
import { Sun, Moon, Sparkles, Check } from "lucide-react";
import { cn } from "@/lib/utils";
const themeIcons: Record<Theme, React.ReactNode> = {
light: <Sun className="h-4 w-4" />,
dark: <Moon className="h-4 w-4" />,
casaos: <Sparkles className="h-4 w-4" />,
};
const themeDot: Record<Theme, string> = {
light: "bg-amber-400",
dark: "bg-indigo-400",
casaos: "bg-pink-400",
};
export function ThemeToggle() { export function ThemeToggle() {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const toggle = () => {
setTheme(theme === "dark" ? "light" : "dark");
};
return ( return (
<DropdownMenu> <Button
<DropdownMenuTrigger asChild> variant="ghost"
<Button variant="ghost" size="icon" className="rounded-lg hover:bg-accent relative" aria-label="Toggle theme"> size="icon"
<div className="relative"> className="rounded-lg hover:bg-accent"
{themeIcons[theme]} aria-label="Toggle theme"
<span className={cn("absolute -bottom-0.5 -right-0.5 h-1.5 w-1.5 rounded-full border-2 border-background", themeDot[theme])} /> onClick={toggle}
</div> >
</Button> {theme === "dark" ? (
</DropdownMenuTrigger> <Sun className="h-4 w-4 text-amber-400" />
<DropdownMenuContent align="end" className="w-40 rounded-xl"> ) : (
{(["light", "dark", "casaos"] as Theme[]).map((t) => ( <Moon className="h-4 w-4 text-foreground" />
<DropdownMenuItem key={t} onClick={() => setTheme(t)} className={cn("gap-2.5 rounded-lg cursor-pointer", theme === t && "bg-accent")}> )}
<span className={cn("flex h-5 w-5 items-center justify-center rounded-md", theme === t ? "text-foreground" : "text-muted-foreground")}> </Button>
{themeIcons[t]}
</span>
<span className="text-sm">{themeLabels[t]}</span>
{theme === t && <Check className="ml-auto h-3.5 w-3.5 text-foreground" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
); );
} }
+1 -1
View File
@@ -8,7 +8,7 @@ const alertVariants = cva(
variants: { variants: {
variant: { variant: {
default: "bg-background text-foreground", default: "bg-background text-foreground",
destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", destructive: "border-destructive text-destructive [&>svg]:text-destructive",
}, },
}, },
defaultVariants: { variant: "default" }, defaultVariants: { variant: "default" },
+3 -3
View File
@@ -11,9 +11,9 @@ const badgeVariants = cva(
secondary: "border-transparent bg-secondary text-secondary-foreground", secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive: "border-transparent bg-destructive text-destructive-foreground", destructive: "border-transparent bg-destructive text-destructive-foreground",
outline: "text-foreground", outline: "text-foreground",
local: "border-transparent bg-blue-500/15 text-blue-500", local: "border-transparent bg-blue-950 text-blue-400",
external: "border-transparent bg-emerald-500/15 text-emerald-500", external: "border-transparent bg-emerald-950 text-emerald-400",
custom: "border-transparent bg-amber-500/15 text-amber-500", custom: "border-transparent bg-amber-950 text-amber-400",
}, },
}, },
defaultVariants: { variant: "default" }, defaultVariants: { variant: "default" },
+1 -1
View File
@@ -25,7 +25,7 @@ const CommandInput = React.forwardRef<
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" /> <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input <CommandPrimitive.Input
ref={ref} ref={ref}
className={cn("flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", className)} className={cn("flex h-10 w-full rounded-md bg-popover py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", className)}
{...props} {...props}
/> />
</div> </div>
+1 -1
View File
@@ -17,7 +17,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "fixed inset-0 z-50 bg-[var(--color-overlay)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className className
)} )}
{...props} {...props}
+1 -1
View File
@@ -16,7 +16,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-border bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", "flex h-9 w-full items-center justify-between gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className, className,
)} )}
{...props} {...props}
+1 -1
View File
@@ -16,7 +16,7 @@ const SheetOverlay = React.forwardRef<
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
className={cn("fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", className)} className={cn("fixed inset-0 z-50 bg-[var(--color-overlay)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", className)}
{...props} {...props}
ref={ref} ref={ref}
/> />
+19 -36
View File
@@ -7,7 +7,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSepara
import { MoreVertical, RefreshCw, Pencil, Trash2, GripVertical, Clock, Shield, ImageIcon, StickyNote, Camera, Activity } from "lucide-react"; import { MoreVertical, RefreshCw, Pencil, Trash2, GripVertical, Clock, Shield, ImageIcon, StickyNote, Camera, Activity } from "lucide-react";
import { useWidgetData, useRefreshWidget } from "@/lib/api/hooks"; import { useWidgetData, useRefreshWidget } from "@/lib/api/hooks";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTheme } from "@/components/providers";
const widgetTypeIcons: Record<string, React.ReactNode> = { const widgetTypeIcons: Record<string, React.ReactNode> = {
clock: <Clock className="h-3.5 w-3.5" />, clock: <Clock className="h-3.5 w-3.5" />,
@@ -17,13 +16,6 @@ const widgetTypeIcons: Record<string, React.ReactNode> = {
immich: <Camera 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({ export function WidgetCard({
widget, widget,
@@ -38,34 +30,25 @@ export function WidgetCard({
}) { }) {
const { data, isLoading, error } = useWidgetData(widget.id); const { data, isLoading, error } = useWidgetData(widget.id);
const refreshMut = useRefreshWidget(); const refreshMut = useRefreshWidget();
const { theme } = useTheme();
const isCasaOS = theme === "casaos";
const handleRefresh = () => refreshMut.mutate(widget.id); const handleRefresh = () => refreshMut.mutate(widget.id);
const statusLabel = data?.status === "stale" ? "stale" : data?.status === "error" ? "error" : ""; const statusLabel = data?.status === "stale" ? "stale" : data?.status === "error" ? "error" : "";
const typeIcon = widgetTypeIcons[widget.type] || <Activity className="h-3.5 w-3.5" />; const typeIcon = widgetTypeIcons[widget.type] || <Activity className="h-3.5 w-3.5" />;
const typeGradient = widgetTypeColors[widget.type] || "from-muted to-muted";
return ( return (
<Card className={cn( <Card className="group relative border-0 overflow-hidden rounded-2xl shadow-[0px_0px_0px_1px_var(--color-border)] hover:shadow-border-hover transition-all duration-200">
"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( <div className={cn(
"absolute top-0 left-0 right-0 h-1 opacity-60", "absolute top-0 left-0 right-0 h-1 opacity-60 bg-ring"
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")}> <CardHeader className="flex flex-row items-center justify-between pt-4 pb-2 px-4">
<div className="flex items-center gap-2.5 min-w-0"> <div className="flex items-center gap-2.5 min-w-0">
{dragHandleProps && ( {dragHandleProps && (
<div {...dragHandleProps} className="cursor-grab opacity-0 group-hover:opacity-60 transition-opacity rounded-md p-0.5 hover:bg-accent"> <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")} /> <GripVertical className="h-4 w-4 text-muted-foreground" />
</div> </div>
)} )}
<div className={cn("flex h-6 w-6 items-center justify-center rounded-md shrink-0", isCasaOS ? "bg-white/10" : "bg-accent")}> <div className="flex h-6 w-6 items-center justify-center rounded-md shrink-0 bg-accent">
{typeIcon} {typeIcon}
</div> </div>
<div className="flex items-center gap-1.5 min-w-0"> <div className="flex items-center gap-1.5 min-w-0">
@@ -75,7 +58,7 @@ export function WidgetCard({
{statusLabel && ( {statusLabel && (
<span className={cn( <span className={cn(
"text-[9px] px-1.5 py-0.5 rounded-full font-medium uppercase shrink-0", "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 === "stale" ? "bg-amber-950 text-amber-400" : "bg-red-950 text-destructive"
)}> )}>
{statusLabel} {statusLabel}
</span> </span>
@@ -83,12 +66,12 @@ export function WidgetCard({
</div> </div>
</div> </div>
<div className="flex items-center gap-0.5 shrink-0"> <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}> <Button variant="ghost" size="icon" className="relative z-10 pointer-events-auto rounded-lg h-7 w-7 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")} /> <RefreshCw className={cn(refreshMut.isPending && "animate-spin", "h-3.5 w-3.5")} />
</Button> </Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <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")}> <Button variant="ghost" size="icon" className="rounded-lg h-7 w-7 hover:bg-accent">
<MoreVertical className="h-3.5 w-3.5" /> <MoreVertical className="h-3.5 w-3.5" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -104,7 +87,7 @@ export function WidgetCard({
</DropdownMenu> </DropdownMenu>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className={cn(isCasaOS ? "px-5 pb-5 pt-1" : "px-4 pb-4 pt-1")}> <CardContent className="px-4 pb-4 pt-1">
{isLoading ? ( {isLoading ? (
<span className="font-mono text-xs text-muted-foreground">[LOADING...]</span> <span className="font-mono text-xs text-muted-foreground">[LOADING...]</span>
) : error || data?.status === "error" ? ( ) : error || data?.status === "error" ? (
@@ -145,7 +128,7 @@ function ClockContent({ config }: { config: Record<string, unknown>; data?: Widg
<div className="font-mono text-3xl tabular-nums tracking-tight text-foreground">{localTime}</div> <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> <div className="text-xs text-muted-foreground font-medium">{localDate}</div>
{timezones.length > 0 && ( {timezones.length > 0 && (
<div className="mt-2 flex flex-col gap-1.5 border-t border-border/30 pt-2"> <div className="mt-2 flex flex-col gap-1.5 border-t border-border pt-2">
{timezones.map((tz) => { {timezones.map((tz) => {
try { try {
const t = new Date().toLocaleTimeString([], { timeZone: tz, hour: "2-digit", minute: "2-digit" }); const t = new Date().toLocaleTimeString([], { timeZone: tz, hour: "2-digit", minute: "2-digit" });
@@ -192,21 +175,21 @@ function PiHoleContent({ data }: { data?: WidgetData }) {
return ( return (
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="rounded-lg bg-emerald-500/10 p-2.5"> <div className="rounded-lg bg-secondary p-2.5">
<div className="text-[10px] uppercase tracking-wider text-emerald-400 font-medium mb-0.5">Status</div> <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")}> <div className={cn("text-sm font-semibold", d.status === "enabled" ? "text-emerald-400" : "text-destructive")}>
{String(d.status || "unknown")} {String(d.status || "unknown")}
</div> </div>
</div> </div>
<div className="rounded-lg bg-blue-500/10 p-2.5"> <div className="rounded-lg bg-secondary p-2.5">
<div className="text-[10px] uppercase tracking-wider text-blue-400 font-medium mb-0.5">Blocked</div> <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 className="font-mono text-sm font-semibold text-foreground">{String(d.ads_blocked_today || "0")}</div>
</div> </div>
<div className="rounded-lg bg-purple-500/10 p-2.5"> <div className="rounded-lg bg-secondary p-2.5">
<div className="text-[10px] uppercase tracking-wider text-purple-400 font-medium mb-0.5">Queries</div> <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 className="font-mono text-sm font-semibold text-foreground">{String(d.dns_queries_today || "0")}</div>
</div> </div>
<div className="rounded-lg bg-amber-500/10 p-2.5"> <div className="rounded-lg bg-secondary p-2.5">
<div className="text-[10px] uppercase tracking-wider text-amber-400 font-medium mb-0.5">% Blocked</div> <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 className="font-mono text-sm font-semibold text-foreground">{String(d.ads_percentage_today || "0")}%</div>
</div> </div>
@@ -222,8 +205,8 @@ function MemosContent({ data }: { data?: WidgetData }) {
return ( return (
<div className="flex flex-col gap-2 max-h-40 overflow-y-auto pr-1"> <div className="flex flex-col gap-2 max-h-40 overflow-y-auto pr-1">
{memos.slice(0, 5).map((m, i) => ( {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 key={i} className="rounded-lg bg-secondary p-2.5 border border-border">
<div className="text-[11px] leading-relaxed line-clamp-2 text-foreground/90"> <div className="text-[11px] leading-relaxed line-clamp-2 text-foreground">
{String(m.content || m.snippet || "")} {String(m.content || m.snippet || "")}
</div> </div>
</div> </div>
@@ -238,11 +221,11 @@ function ImmichContent({ data }: { data?: WidgetData }) {
return ( return (
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="rounded-lg bg-blue-500/10 p-2.5"> <div className="rounded-lg bg-secondary p-2.5">
<div className="text-[10px] uppercase tracking-wider text-blue-400 font-medium mb-0.5">Photos</div> <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 className="font-mono text-sm font-semibold text-foreground">{String(d.photos || "0")}</div>
</div> </div>
<div className="rounded-lg bg-rose-500/10 p-2.5"> <div className="rounded-lg bg-secondary p-2.5">
<div className="text-[10px] uppercase tracking-wider text-rose-400 font-medium mb-0.5">Videos</div> <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 className="font-mono text-sm font-semibold text-foreground">{String(d.videos || "0")}</div>
</div> </div>
+1 -1
View File
@@ -140,7 +140,7 @@ export function WidgetForm({ widget, open, onOpenChange }: WidgetFormProps) {
{tz.split("/").pop()?.replace("_", " ")} {tz.split("/").pop()?.replace("_", " ")}
<button <button
type="button" type="button"
className="ml-0.5 rounded-full hover:bg-foreground/10" className="ml-0.5 rounded-full hover:bg-accent"
onClick={() => setSelectedTzs((prev) => prev.filter((t) => t !== tz))} onClick={() => setSelectedTzs((prev) => prev.filter((t) => t !== tz))}
> >
<X className="h-2.5 w-2.5" /> <X className="h-2.5 w-2.5" />
+2 -2
View File
@@ -10,9 +10,9 @@ test("smoke: theme toggle works", async ({ page }) => {
await page.goto("http://localhost:3000"); await page.goto("http://localhost:3000");
const toggle = page.getByLabel("Toggle theme"); const toggle = page.getByLabel("Toggle theme");
await toggle.click(); await toggle.click();
await page.getByText("CasaOS").click(); await page.getByText("Light").click();
const theme = await page.evaluate(() => document.documentElement.getAttribute("data-theme")); const theme = await page.evaluate(() => document.documentElement.getAttribute("data-theme"));
expect(theme).toBe("casaos"); expect(theme).toBe("light");
}); });
test("smoke: empty state shows add button", async ({ page }) => { test("smoke: empty state shows add button", async ({ page }) => {
+2 -3
View File
@@ -1,11 +1,11 @@
export type Theme = "light" | "dark" | "casaos"; export type Theme = "light" | "dark";
const STORAGE_KEY = "dash-theme"; const STORAGE_KEY = "dash-theme";
export function getStoredTheme(): Theme { export function getStoredTheme(): Theme {
if (typeof window === "undefined") return "dark"; if (typeof window === "undefined") return "dark";
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "light" || stored === "dark" || stored === "casaos") return stored; if (stored === "light" || stored === "dark") return stored;
return "dark"; return "dark";
} }
@@ -22,5 +22,4 @@ export function applyTheme(theme: Theme) {
export const themeLabels: Record<Theme, string> = { export const themeLabels: Record<Theme, string> = {
light: "Light", light: "Light",
dark: "Dark", dark: "Dark",
casaos: "CasaOS",
}; };