feat: add board list home screen with new board dialog and context menu

This commit is contained in:
Your Name
2026-02-15 18:48:59 +02:00
parent d369ae6644
commit fbff4bc855
4 changed files with 376 additions and 9 deletions

View File

@@ -1,6 +1,8 @@
import { useEffect } from "react";
import { useAppStore } from "@/stores/app-store";
import { AppShell } from "@/components/layout/AppShell";
import { BoardList } from "@/components/boards/BoardList";
import { BoardView } from "@/components/board/BoardView";
export default function App() {
const initialized = useAppStore((s) => s.initialized);
@@ -23,15 +25,7 @@ export default function App() {
return (
<AppShell>
{view.type === "board-list" ? (
<div className="flex h-full items-center justify-center text-pylon-text-secondary">
Board List
</div>
) : (
<div className="flex h-full items-center justify-center text-pylon-text-secondary">
Board View
</div>
)}
{view.type === "board-list" ? <BoardList /> : <BoardView />}
</AppShell>
);
}

View File

@@ -0,0 +1,146 @@
import { useState } from "react";
import { formatDistanceToNow } from "date-fns";
import { Trash2, Copy } from "lucide-react";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
ContextMenuSeparator,
} from "@/components/ui/context-menu";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import type { BoardMeta } from "@/types/board";
import { useAppStore } from "@/stores/app-store";
import { useBoardStore } from "@/stores/board-store";
import { deleteBoard, loadBoard, saveBoard } from "@/lib/storage";
interface BoardCardProps {
board: BoardMeta;
}
export function BoardCard({ board }: BoardCardProps) {
const [confirmDelete, setConfirmDelete] = useState(false);
const setView = useAppStore((s) => s.setView);
const addRecentBoard = useAppStore((s) => s.addRecentBoard);
const refreshBoards = useAppStore((s) => s.refreshBoards);
const openBoard = useBoardStore((s) => s.openBoard);
const relativeTime = formatDistanceToNow(new Date(board.updatedAt), {
addSuffix: true,
});
async function handleOpen() {
await openBoard(board.id);
setView({ type: "board", boardId: board.id });
addRecentBoard(board.id);
}
async function handleDelete() {
await deleteBoard(board.id);
await refreshBoards();
setConfirmDelete(false);
}
async function handleDuplicate() {
const original = await loadBoard(board.id);
const { ulid } = await import("ulid");
const ts = new Date().toISOString();
const duplicated = {
...original,
id: ulid(),
title: `${original.title} (copy)`,
createdAt: ts,
updatedAt: ts,
};
await saveBoard(duplicated);
await refreshBoards();
}
return (
<>
<ContextMenu>
<ContextMenuTrigger asChild>
<button
onClick={handleOpen}
className="group flex w-full flex-col rounded-lg bg-pylon-surface shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md text-left"
>
{/* Color accent stripe */}
<div
className="h-1 w-full rounded-t-lg"
style={{ backgroundColor: board.color }}
/>
<div className="flex flex-col gap-2 p-4">
{/* Board title */}
<h3 className="font-heading text-lg text-pylon-text">
{board.title}
</h3>
{/* Stats line */}
<p className="font-mono text-xs text-pylon-text-secondary">
{board.cardCount} card{board.cardCount !== 1 ? "s" : ""} &middot;{" "}
{board.columnCount} column{board.columnCount !== 1 ? "s" : ""}
</p>
{/* Relative time */}
<p className="font-mono text-xs text-pylon-text-secondary">
{relativeTime}
</p>
</div>
</button>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={handleDuplicate}>
<Copy className="size-4" />
Duplicate
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
onClick={() => setConfirmDelete(true)}
>
<Trash2 className="size-4" />
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
{/* Delete confirmation dialog */}
<Dialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<DialogContent className="bg-pylon-surface sm:max-w-sm">
<DialogHeader>
<DialogTitle className="font-heading text-pylon-text">
Delete Board
</DialogTitle>
<DialogDescription className="text-pylon-text-secondary">
Are you sure you want to delete &ldquo;{board.title}&rdquo;? This
action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setConfirmDelete(false)}
className="text-pylon-text-secondary"
>
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,54 @@
import { useState } from "react";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useAppStore } from "@/stores/app-store";
import { BoardCard } from "@/components/boards/BoardCard";
import { NewBoardDialog } from "@/components/boards/NewBoardDialog";
export function BoardList() {
const boards = useAppStore((s) => s.boards);
const [dialogOpen, setDialogOpen] = useState(false);
if (boards.length === 0) {
return (
<>
<div className="flex h-full flex-col items-center justify-center gap-4">
<p className="font-mono text-sm text-pylon-text-secondary">
Create your first board
</p>
<Button onClick={() => setDialogOpen(true)}>
<Plus className="size-4" />
New Board
</Button>
</div>
<NewBoardDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</>
);
}
return (
<>
<div className="h-full overflow-y-auto p-6">
{/* Heading row */}
<div className="mb-4 flex items-center justify-between">
<h2 className="font-mono text-xs font-semibold uppercase tracking-widest text-pylon-text-secondary">
Your Boards
</h2>
<Button size="sm" onClick={() => setDialogOpen(true)}>
<Plus className="size-4" />
New
</Button>
</div>
{/* Board grid */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{boards.map((board) => (
<BoardCard key={board.id} board={board} />
))}
</div>
</div>
<NewBoardDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</>
);
}

View File

@@ -0,0 +1,173 @@
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { createBoard } from "@/lib/board-factory";
import { saveBoard } from "@/lib/storage";
import { useAppStore } from "@/stores/app-store";
import { useBoardStore } from "@/stores/board-store";
const PRESET_COLORS = [
"#6366f1", // indigo
"#8b5cf6", // violet
"#ec4899", // pink
"#f43f5e", // rose
"#f97316", // orange
"#eab308", // yellow
"#22c55e", // green
"#06b6d4", // cyan
];
type Template = "blank" | "kanban" | "sprint";
interface NewBoardDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function NewBoardDialog({ open, onOpenChange }: NewBoardDialogProps) {
const [title, setTitle] = useState("");
const [color, setColor] = useState(PRESET_COLORS[0]);
const [template, setTemplate] = useState<Template>("blank");
const [creating, setCreating] = useState(false);
const refreshBoards = useAppStore((s) => s.refreshBoards);
const setView = useAppStore((s) => s.setView);
const addRecentBoard = useAppStore((s) => s.addRecentBoard);
const openBoard = useBoardStore((s) => s.openBoard);
async function handleCreate() {
const trimmed = title.trim();
if (!trimmed || creating) return;
setCreating(true);
try {
const board = createBoard(trimmed, color, template);
await saveBoard(board);
await refreshBoards();
await openBoard(board.id);
setView({ type: "board", boardId: board.id });
addRecentBoard(board.id);
onOpenChange(false);
resetForm();
} finally {
setCreating(false);
}
}
function resetForm() {
setTitle("");
setColor(PRESET_COLORS[0]);
setTemplate("blank");
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter" && title.trim()) {
handleCreate();
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-pylon-surface sm:max-w-md">
<DialogHeader>
<DialogTitle className="font-heading text-pylon-text">
New Board
</DialogTitle>
<DialogDescription className="text-pylon-text-secondary">
Create a new board to organize your work.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
{/* Title input */}
<div>
<label
htmlFor="board-title"
className="mb-1.5 block font-mono text-xs uppercase tracking-widest text-pylon-text-secondary"
>
Title
</label>
<Input
id="board-title"
placeholder="My Board"
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={handleKeyDown}
autoFocus
className="bg-pylon-bg text-pylon-text"
/>
</div>
{/* Color picker */}
<div>
<label className="mb-1.5 block font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
Color
</label>
<div className="flex gap-2">
{PRESET_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className="size-8 rounded-full transition-transform hover:scale-110"
style={{
backgroundColor: c,
outline:
color === c ? "2px solid currentColor" : "none",
outlineOffset: "2px",
}}
aria-label={`Color ${c}`}
/>
))}
</div>
</div>
{/* Template selector */}
<div>
<label className="mb-1.5 block font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
Template
</label>
<div className="flex gap-2">
{(["blank", "kanban", "sprint"] as const).map((t) => (
<Button
key={t}
type="button"
variant={template === t ? "default" : "outline"}
size="sm"
onClick={() => setTemplate(t)}
className="capitalize"
>
{t}
</Button>
))}
</div>
</div>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
className="text-pylon-text-secondary"
>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={!title.trim() || creating}
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}