For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Project Board 01

A contained workspace for organizing project tickets.

The board fills its parent, so give the parent a height. It uses local sample data. Sort and filter tickets, drag them between stages, or open a ticket to edit its details. With a ticket focused, Alt and the arrow keys move it between and within stages. The detail panel and the new ticket dialog are overlays: pass popupClassName with your theme scope so they match the page.

Source

"use client";

import { cn } from "cn";
import * as React from "react";

import { Board } from "./project-board-columns";
import type { Priority, Stage, Ticket } from "./project-board-data";
import { projects, seed } from "./project-board-data";
import { DetailPanel } from "./project-board-detail-panel";
import { NewTicketDialog } from "./project-board-new-ticket-dialog";
import { Toolbar } from "./project-board-toolbar";
import { useBoardDrag } from "./use-project-board-drag";

/**
 * A five-stage ticket board with a toolbar, a detail panel and a new ticket
 * dialog. It fills its parent, so give the parent a height. The panel and
 * dialog are overlays: pass `popupClassName` (a theme scope) so they pick up
 * the same tokens as the page. `leading` is placed before the title, for a
 * navigation button. `header` swaps the whole breadcrumb and title row for
 * your own, so an app shell can supply it.
 */
export function ProjectBoard({
  className,
  popupClassName,
  leading,
  header,
}: {
  className?: string;
  popupClassName?: string;
  leading?: React.ReactNode;
  /** Replaces the built-in breadcrumb and title row; receives the toolbar buttons. */
  header?: (actions: React.ReactNode) => React.ReactNode;
}) {
  const [tickets, setTickets] = React.useState(seed);
  const [selected, setSelected] = React.useState<string | null>(null);
  const [creating, setCreating] = React.useState(false);
  const [readIds, setReadIds] = React.useState<string[]>([]);
  const [sort, setSort] = React.useState("Manual order");
  const [priority, setPriority] = React.useState("All priorities");
  const [project, setProject] = React.useState("All projects");
  const [showDone, setShowDone] = React.useState(true);
  const [fill, setFill] = React.useState(true);
  const [title, setTitle] = React.useState("");
  const [description, setDescription] = React.useState("");
  const [newStage, setNewStage] = React.useState<Stage | "">("");
  const [newPriority, setNewPriority] = React.useState<Priority | "">("");
  const [newProject, setNewProject] = React.useState<string>("");
  const [newPerson, setNewPerson] = React.useState("");
  const [keep, setKeep] = React.useState(false);
  const [comment, setComment] = React.useState("");
  const [editing, setEditing] = React.useState(false);
  const current = tickets.find((t) => t.id === selected);
  const update = (id: string, patch: Partial<Ticket>) =>
    setTickets((all) => all.map((t) => (t.id === id ? { ...t, ...patch } : t)));
  const move = (id: string, stage: Stage, before?: string) =>
    setTickets((all) => {
      const item = all.find((t) => t.id === id);
      if (!item) {
        return all;
      }
      const rest = all.filter((t) => t.id !== id);
      const index = before ? rest.findIndex((t) => t.id === before) : -1;
      const at =
        index < 0 ? rest.findLastIndex((t) => t.stage === stage) + 1 : index;
      rest.splice(at, 0, { ...item, stage });
      return rest;
    });
  const {
    start: startDrag,
    moved,
    drag,
    dropped,
    settling,
  } = useBoardDrag(move);
  const openCreate = (stage: Stage | "") => {
    setNewStage(stage);
    setCreating(true);
  };
  const create = (event: React.FormEvent) => {
    event.preventDefault();
    if (!title.trim()) {
      return;
    }
    setTickets((all) => [
      {
        assignees: newPerson ? [newPerson] : [],
        comments: [],
        date: "just now",
        description,
        favorite: false,
        group: "Planning",
        id: `PB-${Math.max(...all.map((t) => Number(t.id.slice(3)))) + 1}`,
        priority: newPriority || "Low",
        project: newProject || projects[0],
        stage: newStage || "Backlog",
        title: title.trim(),
      },
      ...all,
    ]);
    setTitle("");
    setDescription("");
    if (!keep) {
      setCreating(false);
    }
  };

  return (
    <div
      data-board
      className={cn(
        "flex h-full min-h-0 flex-col gap-3 text-foreground",
        className
      )}
    >
      <Toolbar
        {...{
          fill,
          header,
          leading,
          openCreate,
          popupClassName,
          priority,
          project,
          readIds,
          setFill,
          setPriority,
          setProject,
          setReadIds,
          setShowDone,
          setSort,
          showDone,
          sort,
        }}
      />
      <Board
        {...{
          drag,
          dropped,
          fill,
          move,
          moved,
          openCreate,
          priority,
          project,
          setSelected,
          settling,
          showDone,
          sort,
          startDrag,
          tickets,
        }}
      />
      <DetailPanel
        {...{
          comment,
          current,
          editing,
          move,
          popupClassName,
          setComment,
          setEditing,
          setSelected,
          update,
        }}
      />
      <NewTicketDialog
        {...{
          create,
          creating,
          description,
          keep,
          newPerson,
          newPriority,
          newProject,
          newStage,
          popupClassName,
          setCreating,
          setDescription,
          setKeep,
          setNewPerson,
          setNewPriority,
          setNewProject,
          setNewStage,
          setTitle,
          tickets,
          title,
        }}
      />
    </div>
  );
}

The block is split into board, column, ticket-card, use-board-drag, toolbar, detail-panel, activity-chart, new-ticket-dialog, shared and data files in the same folder.