mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-29 08:04:58 +00:00
feat: phase 1 MVP — sections, services, drag-drop, renderers
Backend: migration 0002 (sections/items/urls/settings), CRUD + fractional reorder, icon upload, settings, export/import, cached status pings. Handler tests cover reorder, URL scheme validation, settings round-trip. Frontend: shared board store over the generated client, dnd-kit layer (same-section, cross-section, section reorder persisted), URL chooser popover, add/edit service dialog, settings dialog, Bento + Cards renderers behind the shared seam, dark/light theme. Generated with [Devin](https://devin.ai) Authored By: TDvorak <[email protected]> Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
b60821350a
commit
6004105038
37 files changed
+4187
-66
No files matched your search
@@ -0,0 +1,139 @@
|
||||
import { ChevronDown, GripVertical, Pencil, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useItemDnd, useSectionDnd } from "@/board/dnd";
|
||||
import { IconImg, ItemAnchor, StatusChip, hostOf } from "@/board/primitives";
|
||||
import type {
|
||||
BoardViewProps,
|
||||
ItemViewProps,
|
||||
RendererViews,
|
||||
SectionViewProps,
|
||||
} from "@/renderers/types";
|
||||
|
||||
// Bento — default renderer. Mixed-size grid, hairline section headers,
|
||||
// status chips carry the only color. Ported from mockups/d-bento-mono.html.
|
||||
|
||||
function BoardView({ children }: BoardViewProps) {
|
||||
return <div className="flex flex-col gap-5">{children}</div>;
|
||||
}
|
||||
|
||||
function SectionView({ section, onToggle, onDelete, children }: SectionViewProps) {
|
||||
const dnd = useSectionDnd(section.id);
|
||||
return (
|
||||
<section
|
||||
ref={dnd.ref}
|
||||
style={dnd.style}
|
||||
className={cn(dnd.isDragging && "opacity-35")}
|
||||
>
|
||||
<div className="group/sec flex cursor-pointer select-none items-center gap-3 px-0.5 py-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Drag section"
|
||||
className="cursor-grab text-text-faint opacity-0 transition-opacity group-hover/sec:opacity-100 active:cursor-grabbing"
|
||||
{...dnd.attributes}
|
||||
{...dnd.listeners}
|
||||
>
|
||||
<GripVertical size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||
>
|
||||
<h2 className="text-[11px] font-semibold uppercase tracking-[0.14em] text-text-dim">
|
||||
{section.name}
|
||||
</h2>
|
||||
<span className="font-mono text-[11px] text-text-faint">
|
||||
{String(section.items.length).padStart(2, "0")}
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
<ChevronDown
|
||||
size={13}
|
||||
className={cn("text-text-faint transition-transform duration-200", section.collapsed && "-rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Delete section"
|
||||
onClick={onDelete}
|
||||
className="text-text-faint opacity-0 transition-opacity hover:text-destructive group-hover/sec:opacity-100"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{!section.collapsed && children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemView({ item, sectionId, status, onEdit }: ItemViewProps) {
|
||||
const dnd = useItemDnd(sectionId, item.id);
|
||||
const host = item.urls[0] ? hostOf(item.urls[0].url) : "";
|
||||
return (
|
||||
<ItemAnchor item={item} onEdit={onEdit}>
|
||||
{(open) => (
|
||||
<div
|
||||
ref={dnd.ref}
|
||||
style={dnd.style}
|
||||
{...dnd.attributes}
|
||||
{...dnd.listeners}
|
||||
onClick={open}
|
||||
className={cn(
|
||||
"group/tile relative col-span-1 flex min-h-[70px] cursor-grab items-center gap-3",
|
||||
"rounded-[10px] border border-border bg-surface px-[15px] py-[13px]",
|
||||
"transition-colors hover:border-border-strong hover:bg-surface-hover active:scale-[0.98]",
|
||||
"min-[620px]:col-span-2",
|
||||
dnd.isDragging && "opacity-35",
|
||||
)}
|
||||
>
|
||||
<IconImg item={item} size={40} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[13.5px] font-medium tracking-tight">{item.name}</div>
|
||||
{host ? <div className="truncate font-mono text-[10.5px] text-text-faint">{host}</div> : null}
|
||||
</div>
|
||||
{item.urls.length > 0 && <StatusChip status={status} />}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Edit ${item.name}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
className="absolute right-2 top-2 rounded p-1 text-text-faint opacity-0 transition-opacity hover:text-text group-hover/tile:opacity-100"
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</ItemAnchor>
|
||||
);
|
||||
}
|
||||
|
||||
// Widget tiles land in Phase 2; render a quiet placeholder tile meanwhile.
|
||||
function WidgetView({ item, sectionId }: ItemViewProps) {
|
||||
const dnd = useItemDnd(sectionId, item.id);
|
||||
return (
|
||||
<div
|
||||
ref={dnd.ref}
|
||||
style={dnd.style}
|
||||
{...dnd.attributes}
|
||||
{...dnd.listeners}
|
||||
className={cn(
|
||||
"col-span-1 flex min-h-[70px] items-center rounded-[10px] border border-dashed border-border-strong px-[15px] py-[13px]",
|
||||
"min-[620px]:col-span-2",
|
||||
dnd.isDragging && "opacity-35",
|
||||
)}
|
||||
>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.12em] text-text-faint">
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const bento: RendererViews = {
|
||||
BoardView,
|
||||
SectionView,
|
||||
ItemView,
|
||||
WidgetView,
|
||||
itemsGridClass: "grid grid-cols-2 min-[620px]:grid-cols-4 min-[900px]:grid-cols-6 gap-2.5",
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
import { ChevronDown, GripVertical, Pencil, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useItemDnd, useSectionDnd } from "@/board/dnd";
|
||||
import { IconImg, ItemAnchor, StatusChip, hostOf } from "@/board/primitives";
|
||||
import type {
|
||||
BoardViewProps,
|
||||
ItemViewProps,
|
||||
RendererViews,
|
||||
SectionViewProps,
|
||||
} from "@/renderers/types";
|
||||
|
||||
// Cards — uniform compact grid. Second renderer; proves the seam while the
|
||||
// code is small. Ported from mockups/a-vercel-mono.html.
|
||||
|
||||
function BoardView({ children }: BoardViewProps) {
|
||||
return <div className="flex flex-col gap-10">{children}</div>;
|
||||
}
|
||||
|
||||
function SectionView({ section, onToggle, onDelete, children }: SectionViewProps) {
|
||||
const dnd = useSectionDnd(section.id);
|
||||
return (
|
||||
<section ref={dnd.ref} style={dnd.style} className={cn(dnd.isDragging && "opacity-40")}>
|
||||
<div className="group/sec mb-3.5 flex cursor-pointer select-none items-center gap-2.5 px-1 py-1.5 text-text-dim">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex min-w-0 flex-1 items-center gap-2.5 text-left"
|
||||
>
|
||||
<h2 className="text-[11px] font-semibold uppercase tracking-[0.14em]">{section.name}</h2>
|
||||
<span className="text-[11px] tabular-nums text-text-faint">{section.items.length}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Drag section"
|
||||
className="cursor-grab text-text-faint opacity-0 transition-opacity group-hover/sec:opacity-100 active:cursor-grabbing"
|
||||
{...dnd.attributes}
|
||||
{...dnd.listeners}
|
||||
>
|
||||
<GripVertical size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Delete section"
|
||||
onClick={onDelete}
|
||||
className="text-text-faint opacity-0 transition-opacity hover:text-destructive group-hover/sec:opacity-100"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
<ChevronDown
|
||||
size={13}
|
||||
className={cn("text-text-faint transition-transform duration-200", section.collapsed && "-rotate-90")}
|
||||
/>
|
||||
</div>
|
||||
{!section.collapsed && children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemView({ item, sectionId, status, onEdit }: ItemViewProps) {
|
||||
const dnd = useItemDnd(sectionId, item.id);
|
||||
const host = item.urls[0] ? hostOf(item.urls[0].url) : "";
|
||||
return (
|
||||
<ItemAnchor item={item} onEdit={onEdit}>
|
||||
{(open) => (
|
||||
<div
|
||||
ref={dnd.ref}
|
||||
style={dnd.style}
|
||||
{...dnd.attributes}
|
||||
{...dnd.listeners}
|
||||
onClick={open}
|
||||
className={cn(
|
||||
"group/card relative flex cursor-grab items-center gap-3",
|
||||
"rounded-[10px] border border-border bg-surface px-3.5 py-3",
|
||||
"transition-colors hover:border-border-strong hover:bg-surface-hover active:scale-[0.98]",
|
||||
dnd.isDragging && "opacity-40",
|
||||
)}
|
||||
>
|
||||
<IconImg item={item} size={36} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[13.5px] font-medium tracking-tight">{item.name}</div>
|
||||
{host ? <div className="truncate text-[11.5px] text-text-faint">{host}</div> : null}
|
||||
</div>
|
||||
{item.urls.length > 1 && (
|
||||
<span className="shrink-0 rounded border border-border px-1 py-px text-[10px] tabular-nums text-text-faint">
|
||||
{item.urls.length}
|
||||
</span>
|
||||
)}
|
||||
{item.urls.length > 0 && <StatusChip status={status} variant="dot" />}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Edit ${item.name}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
className="absolute -right-1 -top-1 rounded-full border border-border bg-popover p-1 text-text-faint opacity-0 transition-opacity hover:text-text group-hover/card:opacity-100"
|
||||
>
|
||||
<Pencil size={10} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</ItemAnchor>
|
||||
);
|
||||
}
|
||||
|
||||
function WidgetView({ item, sectionId }: ItemViewProps) {
|
||||
const dnd = useItemDnd(sectionId, item.id);
|
||||
return (
|
||||
<div
|
||||
ref={dnd.ref}
|
||||
style={dnd.style}
|
||||
{...dnd.attributes}
|
||||
{...dnd.listeners}
|
||||
className={cn(
|
||||
"flex items-center rounded-[10px] border border-dashed border-border-strong bg-surface px-4 py-3",
|
||||
dnd.isDragging && "opacity-40",
|
||||
)}
|
||||
>
|
||||
<span className="text-[10.5px] font-semibold uppercase tracking-[0.12em] text-text-faint">
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const cards: RendererViews = {
|
||||
BoardView,
|
||||
SectionView,
|
||||
ItemView,
|
||||
WidgetView,
|
||||
itemsGridClass: "grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2.5",
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { bento } from "./bento";
|
||||
import { cards } from "./cards";
|
||||
import type { RendererViews } from "./types";
|
||||
import type { RendererName } from "@/types";
|
||||
|
||||
// Renderer registry — settings.renderer picks the active one.
|
||||
export const rendererList = ["bento", "cards"] as const satisfies readonly RendererName[];
|
||||
export const renderers: Record<RendererName, RendererViews> = { bento, cards };
|
||||
|
||||
export const rendererLabels: Record<RendererName, string> = {
|
||||
bento: "Bento",
|
||||
cards: "Cards",
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import type { Item, Section, Status } from "@/types";
|
||||
|
||||
// Renderer contract — same board state, shared dnd/popover/dialog primitives,
|
||||
// different structure per style. New renderers implement this shape only.
|
||||
// Dnd stays shared: views call useSectionDnd / useItemDnd hooks internally.
|
||||
|
||||
export interface SectionViewProps {
|
||||
section: Section;
|
||||
onToggle(): void;
|
||||
onDelete(): void;
|
||||
children: ReactNode; // rendered items grid (ItemSortZone output)
|
||||
}
|
||||
|
||||
export interface ItemViewProps {
|
||||
item: Item;
|
||||
sectionId: string;
|
||||
status?: Status | undefined;
|
||||
onEdit(): void;
|
||||
}
|
||||
|
||||
export interface BoardViewProps {
|
||||
children: ReactNode; // rendered sections
|
||||
}
|
||||
|
||||
export interface RendererViews {
|
||||
/** Arrangement wrapper around the section list. */
|
||||
BoardView: ComponentType<BoardViewProps>;
|
||||
/** Section chrome: label, count, collapse, drag grip. */
|
||||
SectionView: ComponentType<SectionViewProps>;
|
||||
/** Service tile anatomy: icon, name, host, status, launch anchor. */
|
||||
ItemView: ComponentType<ItemViewProps>;
|
||||
/** Widget tile anatomy (Phase 2 fills in). */
|
||||
WidgetView: ComponentType<ItemViewProps>;
|
||||
/** Tailwind classes for the items grid inside a section. */
|
||||
itemsGridClass: string;
|
||||
}
|
||||
Reference in new issue
Block a user