Dashboard: drag-to-reorder + corner-resize via Edit layout mode
87fa9c56224defad0ef0a70c668da18def5cb11c
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-06-09 13:25
parent ca07f949
Dashboard: drag-to-reorder + corner-resize via Edit layout mode Cards can now be rearranged and freely resized directly on the dashboard. An "Edit layout" toggle reveals dashed tile outlines, makes every card a dnd-kit drag source for reordering, and shows a bottom-right corner handle that resizes the card by whole grid cells (free w×h). While editing, card content interaction is paused. Layout model moves from the 5-name size enum to explicit cols/rows spans: - db/schema.ts: add cols (default 1) + rows (default 2); size kept but now vestigial. Backfilled existing rows from their old size. - schema/types/store/import updated to carry cols/rows. - lib/layout.ts: spanStyle(cols, rows) + grid constants; CardBody's short-tile heuristic now keys off rows. - Settings: drop the per-row size dropdown (resize lives on the dashboard now); reorder arrows stay as an accessible fallback. Position changes renumber only enabled cards into their existing position slots, leaving hidden sources untouched. cols/rows persist via the existing PATCH /api/sources/[id]. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
11 files changed
components/Dashboard.tsx +237 −26
@@ -1,10 +1,25 @@
"use client";
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
+import {
+ DndContext,
+ PointerSensor,
+ closestCenter,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+} from "@dnd-kit/core";
+import {
+ SortableContext,
+ arrayMove,
+ rectSortingStrategy,
+ useSortable,
+} from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
import type { SourceState } from "@/lib/types";
import { useAction } from "@/hooks/useAction";
-import { tileStyle } from "@/lib/layout";
+import { spanStyle, GRID_COL_MIN, GRID_ROW_PX, GRID_GAP, MAX_ROWS } from "@/lib/layout";
import { SourceCard } from "./cards/SourceCard";
import { ThingsCard } from "./cards/ThingsCard";
@@ -15,6 +30,7 @@ const [loading, setLoading] = useState(true);
const [refreshingAll, setRefreshingAll] = useState(false);
const [refreshingId, setRefreshingId] = useState<string | null>(null);
const [generating, setGenerating] = useState(false);
+ const [editing, setEditing] = useState(false);
// Initial paint from cache, then refresh stale sources in the background.
useEffect(() => {
@@ -65,6 +81,59 @@ setGenerating(false);
}
}, [router]);
+ // Persist a layout patch (position / cols / rows) for one source.
+ const patchSource = useCallback((id: string, patch: Record<string, number>) => {
+ void fetch(`/api/sources/${id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(patch),
+ });
+ }, []);
+
+ // Drag-reorder: renumber enabled cards into the same set of position slots they
+ // already occupy, leaving disabled (hidden) sources' positions untouched.
+ const onDragEnd = useCallback(
+ (e: DragEndEvent) => {
+ const { active, over } = e;
+ if (!over || active.id === over.id) return;
+ setItems((prev) => {
+ const enabled = prev.filter((it) => it.source.enabled);
+ const oldIndex = enabled.findIndex((it) => it.source.id === active.id);
+ const newIndex = enabled.findIndex((it) => it.source.id === over.id);
+ if (oldIndex < 0 || newIndex < 0) return prev;
+ const slots = enabled.map((it) => it.source.position).sort((a, b) => a - b);
+ const reordered = arrayMove(enabled, oldIndex, newIndex);
+ const posById = new Map<string, number>();
+ reordered.forEach((it, i) => {
+ const pos = slots[i];
+ if (pos !== it.source.position) patchSource(it.source.id, { position: pos });
+ posById.set(it.source.id, pos);
+ });
+ return prev
+ .map((it) =>
+ posById.has(it.source.id)
+ ? { ...it, source: { ...it.source, position: posById.get(it.source.id)! } }
+ : it,
+ )
+ .sort((a, b) => a.source.position - b.source.position);
+ });
+ },
+ [patchSource],
+ );
+
+ // Live local resize, persisted on release.
+ const resize = useCallback(
+ (id: string, cols: number, rows: number, commit: boolean) => {
+ setItems((prev) =>
+ prev.map((it) =>
+ it.source.id === id ? { ...it, source: { ...it.source, cols, rows } } : it,
+ ),
+ );
+ if (commit) patchSource(id, { cols, rows });
+ },
+ [patchSource],
+ );
+
useAction(
{
id: "refresh-all",
@@ -86,7 +155,18 @@ },
[generatePaper],
);
+ const sensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
+ );
+
const enabled = items.filter((it) => it.source.enabled);
+ const gridStyle: React.CSSProperties = {
+ display: "grid",
+ gridTemplateColumns: `repeat(auto-fill, minmax(${GRID_COL_MIN}px, 1fr))`,
+ gridAutoRows: `${GRID_ROW_PX}px`,
+ gridAutoFlow: "dense",
+ gap: GRID_GAP,
+ };
return (
<main className="w-full px-6 py-8">
@@ -99,47 +179,178 @@ day: "numeric",
})}
</p>
<div className="flex gap-2">
+ <Btn onClick={() => setEditing((v) => !v)} primary={editing}>
+ {editing ? "Done" : "Edit layout"}
+ </Btn>
<Btn onClick={generatePaper} disabled={generating}>
{generating ? "Generating…" : "Generate paper"}
</Btn>
- <Btn onClick={refreshAll} disabled={refreshingAll} primary>
+ <Btn onClick={refreshAll} disabled={refreshingAll} primary={!editing}>
{refreshingAll ? "Refreshing…" : "Refresh all"}
</Btn>
</div>
</div>
+ {editing && (
+ <p className="text-xs mb-3" style={{ color: "var(--text-faint)" }}>
+ Drag cards to reorder · drag a card’s bottom-right corner to resize · click
+ Done when finished.
+ </p>
+ )}
+
{loading ? (
<p style={{ color: "var(--text-faint)" }}>Loading…</p>
+ ) : editing ? (
+ <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
+ <SortableContext items={enabled.map((it) => it.source.id)} strategy={rectSortingStrategy}>
+ <div style={gridStyle}>
+ {enabled.map((it) => (
+ <SortableTile key={it.source.id} state={it} onResize={resize}>
+ <CardFor
+ state={it}
+ onRefresh={() => refreshOne(it.source.id)}
+ refreshing={refreshingId === it.source.id}
+ />
+ </SortableTile>
+ ))}
+ </div>
+ </SortableContext>
+ </DndContext>
) : (
- <div
- style={{
- display: "grid",
- gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
- gridAutoRows: "128px",
- gridAutoFlow: "dense",
- gap: 16,
- }}
- >
+ <div style={gridStyle}>
{enabled.map((it) => (
- <div key={it.source.id} style={{ ...tileStyle(it.source.size), minHeight: 0 }}>
- {it.source.kind === "todos" ? (
- <ThingsCard
- state={it}
- onRefresh={() => refreshOne(it.source.id)}
- refreshing={refreshingId === it.source.id}
- />
- ) : (
- <SourceCard
- state={it}
- onRefresh={() => refreshOne(it.source.id)}
- refreshing={refreshingId === it.source.id}
- />
- )}
+ <div
+ key={it.source.id}
+ style={{ ...spanStyle(it.source.cols, it.source.rows), minHeight: 0 }}
+ >
+ <CardFor
+ state={it}
+ onRefresh={() => refreshOne(it.source.id)}
+ refreshing={refreshingId === it.source.id}
+ />
</div>
))}
</div>
)}
</main>
+ );
+}
+
+function CardFor({
+ state,
+ onRefresh,
+ refreshing,
+}: {
+ state: SourceState;
+ onRefresh: () => void;
+ refreshing: boolean;
+}) {
+ return state.source.kind === "todos" ? (
+ <ThingsCard state={state} onRefresh={onRefresh} refreshing={refreshing} />
+ ) : (
+ <SourceCard state={state} onRefresh={onRefresh} refreshing={refreshing} />
+ );
+}
+
+// A draggable, corner-resizable grid tile (edit mode only).
+function SortableTile({
+ state,
+ onResize,
+ children,
+}: {
+ state: SourceState;
+ onResize: (id: string, cols: number, rows: number, commit: boolean) => void;
+ children: React.ReactNode;
+}) {
+ const { source } = state;
+ const { setNodeRef, attributes, listeners, transform, transition, isDragging } = useSortable({
+ id: source.id,
+ });
+ const nodeRef = useRef<HTMLDivElement | null>(null);
+
+ // Corner-resize: translate pointer movement into whole grid-cell span changes.
+ const onResizeStart = (e: React.PointerEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ const grid = nodeRef.current?.parentElement;
+ if (!grid) return;
+ const cols = getComputedStyle(grid).gridTemplateColumns.split(" ").filter(Boolean).length || 1;
+ const cellW = (grid.clientWidth - GRID_GAP * (cols - 1)) / cols;
+ const stepX = cellW + GRID_GAP;
+ const stepY = GRID_ROW_PX + GRID_GAP;
+ const startX = e.clientX;
+ const startY = e.clientY;
+ const startCols = source.cols;
+ const startRows = source.rows;
+ (e.target as Element).setPointerCapture(e.pointerId);
+
+ const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
+ const move = (ev: PointerEvent) => {
+ const nextCols = clamp(startCols + Math.round((ev.clientX - startX) / stepX), 1, cols);
+ const nextRows = clamp(startRows + Math.round((ev.clientY - startY) / stepY), 1, MAX_ROWS);
+ onResize(source.id, nextCols, nextRows, false);
+ };
+ const up = (ev: PointerEvent) => {
+ const nextCols = clamp(startCols + Math.round((ev.clientX - startX) / stepX), 1, cols);
+ const nextRows = clamp(startRows + Math.round((ev.clientY - startY) / stepY), 1, MAX_ROWS);
+ onResize(source.id, nextCols, nextRows, true);
+ window.removeEventListener("pointermove", move);
+ window.removeEventListener("pointerup", up);
+ };
+ window.addEventListener("pointermove", move);
+ window.addEventListener("pointerup", up);
+ };
+
+ return (
+ <div
+ ref={(el) => {
+ setNodeRef(el);
+ nodeRef.current = el;
+ }}
+ style={{
+ ...spanStyle(source.cols, source.rows),
+ minHeight: 0,
+ position: "relative",
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.4 : 1,
+ zIndex: isDragging ? 10 : undefined,
+ cursor: "grab",
+ outline: "2px dashed var(--border-strong)",
+ outlineOffset: -2,
+ borderRadius: 8,
+ }}
+ {...attributes}
+ {...listeners}
+ >
+ {/* Block inner clicks/scroll while editing; this overlay is the drag surface. */}
+ <div style={{ pointerEvents: "none", height: "100%" }}>{children}</div>
+
+ {/* Resize handle — bottom-right corner. */}
+ <div
+ onPointerDown={onResizeStart}
+ title="Drag to resize"
+ style={{
+ position: "absolute",
+ right: 2,
+ bottom: 2,
+ width: 16,
+ height: 16,
+ cursor: "nwse-resize",
+ color: "var(--text-on-accent)",
+ background: "var(--accent)",
+ borderRadius: 4,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ fontSize: 10,
+ lineHeight: 1,
+ zIndex: 5,
+ }}
+ >
+ ⤡
+ </div>
+ </div>
);
}
components/SettingsView.tsx +2 −16
@@ -7,8 +7,6 @@ import { downloadExport } from "@/lib/export";
import { uploadImport } from "@/lib/import";
import type { SourceState } from "@/lib/types";
import type { Settings } from "@/lib/schemas/setting";
-import { SOURCE_SIZES } from "@/lib/schemas/source";
-import { SIZE_LABELS } from "@/lib/layout";
const cardStyle = {
background: "var(--bg-card)",
@@ -204,7 +202,8 @@ ))}
</div>
<p className="mt-3 text-xs" style={{ color: "var(--text-faint)" }}>
Uncheck a card to hide it from the dashboard. Every source stays available — nothing
- is ever removed.
+ is ever removed. Drag to reorder and resize cards from the dashboard’s
+ “Edit layout” mode.
</p>
</Section>
@@ -276,19 +275,6 @@ <RefreshControl
seconds={source.refreshSeconds}
onChange={(s) => onPatch({ refreshSeconds: s })}
/>
- <select
- value={source.size}
- onChange={(e) => onPatch({ size: e.target.value })}
- className="text-xs px-1 py-0.5"
- style={inputStyle}
- aria-label="Tile size"
- >
- {SOURCE_SIZES.map((s) => (
- <option key={s} value={s}>
- {SIZE_LABELS[s]}
- </option>
- ))}
- </select>
<button className="text-xs px-1" style={{ color: "var(--text-muted)" }} onClick={onUp} disabled={!onUp}>↑</button>
<button className="text-xs px-1" style={{ color: "var(--text-muted)" }} onClick={onDown} disabled={!onDown}>↓</button>
{configurable && (
components/cards/CardBodies.tsx +4 −5
@@ -15,8 +15,7 @@ import type { BlueskyPayload } from "@/lib/schemas/sources/bluesky";
import type { CalendarPayload } from "@/lib/schemas/sources/calendar";
import type { SportsPayload } from "@/lib/schemas/sources/sports";
import type { LinksPayload } from "@/lib/schemas/sources/links";
-import type { SourceKind, SourceSize } from "@/lib/schemas/source";
-import { TILE_SPANS } from "@/lib/layout";
+import type { SourceKind } from "@/lib/schemas/source";
const muted = { color: "var(--text-muted)" };
const faint = { color: "var(--text-faint)" };
@@ -323,13 +322,13 @@
export function CardBody({
kind,
payload,
- size,
+ rows,
}: {
kind: SourceKind;
payload: unknown;
- size: SourceSize;
+ rows: number;
}) {
- const shortTile = (TILE_SPANS[size]?.row ?? 2) <= 1;
+ const shortTile = rows <= 1;
switch (kind) {
case "weather":
return <WeatherBody p={payload as WeatherPayload} row={shortTile} />;
components/cards/SourceCard.tsx +1 −1
@@ -88,7 +88,7 @@ body = <Note>Loading…</Note>;
} else if (!snapshot.ok) {
body = <Note tone="error">{snapshot.error ?? "Failed to load."}</Note>;
} else {
- body = <CardBody kind={source.kind} payload={snapshot.payload} size={source.size} />;
+ body = <CardBody kind={source.kind} payload={snapshot.payload} rows={source.rows} />;
}
return (
db/schema.ts +3 −1
@@ -18,7 +18,9 @@ enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
config: text("config", { mode: "json" }).notNull().default(sql`'{}'`),
refreshSeconds: integer("refresh_seconds").notNull().default(900),
position: integer("position").notNull().default(0),
- size: text("size").notNull().default("md"),
+ size: text("size").notNull().default("md"), // vestigial; layout now uses cols/rows
+ cols: integer("cols").notNull().default(1), // grid column span (free w×h bento)
+ rows: integer("rows").notNull().default(2), // grid row span
});
// Cached fetch result per source.
lib/layout.ts +14 −0
@@ -20,6 +20,20 @@ gridRow: `span ${span.row}`,
};
}
+// Free w×h bento: a card spans `cols` columns and `rows` rows directly.
+// The grid track is GRID_COL_MIN wide and GRID_ROW_PX tall (see Dashboard).
+export const GRID_COL_MIN = 280; // px — min column width (auto-fill minmax)
+export const GRID_ROW_PX = 128; // px — fixed row height
+export const GRID_GAP = 16; // px — grid gap
+export const MAX_ROWS = 8; // resize clamp for row span
+
+export function spanStyle(cols: number, rows: number): React.CSSProperties {
+ return {
+ gridColumn: `span ${Math.max(1, cols)}`,
+ gridRow: `span ${Math.max(1, rows)}`,
+ };
+}
+
export const SIZE_LABELS: Record<SourceSize, string> = {
sm: "Small",
md: "Medium",
lib/schemas/source.ts +3 −1
@@ -40,7 +40,9 @@ enabled: z.boolean().default(true),
config: SourceConfig.default({}),
refreshSeconds: z.number().int().min(30).max(86_400).default(900),
position: z.number().int().default(0),
- size: SourceSize.default("md"),
+ size: SourceSize.default("md"), // vestigial; layout now uses cols/rows
+ cols: z.number().int().min(1).max(6).default(1), // grid column span
+ rows: z.number().int().min(1).max(8).default(2), // grid row span
});
export type Source = z.infer<typeof Source>;
lib/store.ts +4 −0
@@ -29,6 +29,8 @@ config: Record<string, unknown>;
refreshSeconds: number;
position: number;
size: string;
+ cols: number;
+ rows: number;
}>,
) {
if (Object.keys(patch).length > 0) {
@@ -188,6 +190,8 @@ enabled: s.enabled !== false,
config: (s.config as Record<string, unknown>) ?? {},
refreshSeconds: Number(s.refreshSeconds ?? 900),
position: Number(s.position ?? 0),
+ cols: Number(s.cols ?? 1),
+ rows: Number(s.rows ?? 2),
})
.run();
}
lib/types.ts +2 −0
@@ -11,6 +11,8 @@ config: Record<string, unknown>;
refreshSeconds: number;
position: number;
size: SourceSize;
+ cols: number;
+ rows: number;
}
export interface ClientSnapshot {
package.json +4 −0
@@ -15,6 +15,10 @@ "db:seed": "tsx scripts/seed.ts",
"icons": "tsx scripts/generate-icons.mjs"
},
"dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/modifiers": "^9.0.0",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"better-sqlite3": "^11.8.1",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.45.0",
pnpm-lock.yaml +72 −0
@@ -8,6 +8,18 @@ importers:
.:
dependencies:
+ '@dnd-kit/core':
+ specifier: ^6.3.1
+ version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@dnd-kit/modifiers':
+ specifier: ^9.0.0
+ version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
+ '@dnd-kit/sortable':
+ specifier: ^10.0.0
+ version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
+ '@dnd-kit/utilities':
+ specifier: ^3.2.2
+ version: 3.2.2(react@19.2.6)
better-sqlite3:
specifier: ^11.8.1
version: 11.10.0
@@ -95,6 +107,34 @@
'@babel/helper-validator-identifier@7.28.5':
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'}
+
+ '@dnd-kit/accessibility@3.1.1':
+ resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
+ peerDependencies:
+ react: '>=16.8.0'
+
+ '@dnd-kit/core@6.3.1':
+ resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+
+ '@dnd-kit/modifiers@9.0.0':
+ resolution: {integrity: sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==}
+ peerDependencies:
+ '@dnd-kit/core': ^6.3.0
+ react: '>=16.8.0'
+
+ '@dnd-kit/sortable@10.0.0':
+ resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
+ peerDependencies:
+ '@dnd-kit/core': ^6.3.0
+ react: '>=16.8.0'
+
+ '@dnd-kit/utilities@3.2.2':
+ resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
+ peerDependencies:
+ react: '>=16.8.0'
'@drizzle-team/brocli@0.10.2':
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
@@ -2036,6 +2076,38 @@ js-tokens: 4.0.0
picocolors: 1.1.1
'@babel/helper-validator-identifier@7.28.5': {}
+
+ '@dnd-kit/accessibility@3.1.1(react@19.2.6)':
+ dependencies:
+ react: 19.2.6
+ tslib: 2.8.1
+
+ '@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+ dependencies:
+ '@dnd-kit/accessibility': 3.1.1(react@19.2.6)
+ '@dnd-kit/utilities': 3.2.2(react@19.2.6)
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+ tslib: 2.8.1
+
+ '@dnd-kit/modifiers@9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)':
+ dependencies:
+ '@dnd-kit/core': 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@dnd-kit/utilities': 3.2.2(react@19.2.6)
+ react: 19.2.6
+ tslib: 2.8.1
+
+ '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)':
+ dependencies:
+ '@dnd-kit/core': 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@dnd-kit/utilities': 3.2.2(react@19.2.6)
+ react: 19.2.6
+ tslib: 2.8.1
+
+ '@dnd-kit/utilities@3.2.2(react@19.2.6)':
+ dependencies:
+ react: 19.2.6
+ tslib: 2.8.1
'@drizzle-team/brocli@0.10.2': {}