JP
HomeBlogProjectsResumeAbout

© 2026 JP. All rights reserved.

Back to blog
tutorialprismanextjsnpmStorybookcssTailwind

Building Solo Kanban: A Developer's Project Board with Next.js and @dxsolo/ui Part 5

AdminMarch 23, 202620 min read
Building Solo Kanban: A Developer's Project Board with Next.js and @dxsolo/ui Part 5

On this page

  • Part 5: Projects, Epics, and Filtering
  • What the Sidebar Needs to Do
  • Extending @dxsolo/ui: Two New Components
  • Component 1: ColorPicker
  • Component 2: AlertDialog
  • Storybook Stories
  • Exporting the New Components
  • Commit and Publish
  • Server Actions
  • Project Actions
  • Epic Actions
  • Create Issue Action
  • Filtering with URL Search Params
  • Updating the Board Page
  • Rebuilding the Sidebar
  • Project Switcher
  • Epic List with Filtering
  • Epic CRUD
  • Creating Issues from the Board
  • The Create Issue Form
  • Adding the "+" Button to Columns
  • Updating the Board
  • Updating the Home Page
  • Run It
  • Catching a Focus Bug
  • Handling Edge Cases
  • Filtering and Drag-and-Drop
  • Creating Issues While Filtered
  • Deleting a Filtered Epic
  • Project Deletion
  • Ticket Numbers and Done State
  • Adding a Number Field to the Schema
  • The Migration
  • Updating the Seed
  • Updating the Create Issue Action
  • Updating the Issue Card
  • Full File Reference
  • What We Built in Part 5
  • Troubleshooting
  • What is Next

On this page

  • Part 5: Projects, Epics, and Filtering
  • What the Sidebar Needs to Do
  • Extending @dxsolo/ui: Two New Components
  • Component 1: ColorPicker
  • Component 2: AlertDialog
  • Storybook Stories
  • Exporting the New Components
  • Commit and Publish
  • Server Actions
  • Project Actions
  • Epic Actions
  • Create Issue Action
  • Filtering with URL Search Params
  • Updating the Board Page
  • Rebuilding the Sidebar
  • Project Switcher
  • Epic List with Filtering
  • Epic CRUD
  • Creating Issues from the Board
  • The Create Issue Form
  • Adding the "+" Button to Columns
  • Updating the Board
  • Updating the Home Page
  • Run It
  • Catching a Focus Bug
  • Handling Edge Cases
  • Filtering and Drag-and-Drop
  • Creating Issues While Filtered
  • Deleting a Filtered Epic
  • Project Deletion
  • Ticket Numbers and Done State
  • Adding a Number Field to the Schema
  • The Migration
  • Updating the Seed
  • Updating the Create Issue Action
  • Updating the Issue Card
  • Full File Reference
  • What We Built in Part 5
  • Troubleshooting
  • What is Next
PreviousBuilding Solo Kanban: A Developer's Project Board with Next.js and @dxsolo/ui Part 4NextBuilding Solo Kanban: A Developer's Project Board with Next.js and @dxsolo/ui Part 6

Building Solo Kanban: A Developer's Project Board with Next.js and @dxsolo/ui

Part 5: Projects, Epics, and Filtering

Welcome back. In Part 4, we built the issue detail view — a modal with markdown editing, subtask management, and debounced auto-save. You can click a card, edit everything about it, and close the modal. Changes persist.

But there is a problem. The board has one hardcoded project from the seed data. There is no way to create new projects, manage epics, or filter the board. The sidebar says "Epic filters coming in Part 5." Time to deliver.

By the end of this post:

  • A project switcher lets you create and switch between projects
  • The sidebar lists epics with color dots, and clicking one filters the board
  • Epics can be created, edited, and deleted — with a color picker for the label color
  • A "+" button in each column header lets you create issues directly on the board
  • Two new components join @dxsolo/ui: ColorPicker and AlertDialog

Series overview:

  1. Project Setup and Data Modeling
  2. The Board Layout: CSS Grid, Container Queries, and Column Architecture
  3. Drag and Drop That Actually Works
  4. The Issue Detail View with Markdown Editing
  5. Projects, Epics, and Filtering
  6. Polish, Persistence, and Publishing

What the Sidebar Needs to Do

Right now the sidebar is mostly decorative. Here is what it needs to become:

  1. Project switcher. A dropdown showing all projects. Selecting one navigates to that project's board. A button to create new projects.
  2. Epic list. Each epic shows its color dot and name. Clicking an epic toggles a board filter. A button to create epics, and a way to edit or delete them.
  3. Active filter indicator. When filtering by epic, the board shows only matching issues. The sidebar shows which filter is active.

This is a lot of interactivity. The sidebar needs project data, epic data, the current filter state, and callbacks for CRUD operations. We will use URL search params for the filter state — this keeps the filter shareable and bookmarkable, and it works cleanly with Next.js server components.


Extending @dxsolo/ui: Two New Components

Before touching the kanban app, we need two new primitives in the component library.

Component 1: ColorPicker

Epic colors are user-chosen. We need a way to pick a color. A full color wheel is overkill — a grid of curated swatches is simpler, looks better, and prevents users from choosing colors that clash with the UI.

bash
cd path/to/jpComponent/my-ui

Create lib/components/ColorPicker/ColorPicker.tsx:

tsx
// lib/components/ColorPicker/ColorPicker.tsx
import { forwardRef } from 'react';
import { cn } from '../../utils/cn';
 
const DEFAULT_COLORS = [
  '#6366f1', // indigo
  '#8b5cf6', // violet
  '#ec4899', // pink
  '#ef4444', // red
  '#f97316', // orange
  '#f59e0b', // amber
  '#84cc16', // lime
  '#10b981', // emerald
  '#06b6d4', // cyan
  '#3b82f6', // blue
];
 
type ColorPickerProps = {
  value?: string;
  onChange?: (color: string) => void;
  colors?: string[];
  className?: string;
  label?: string;
};
 
export const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
  ({ value, onChange, colors = DEFAULT_COLORS, className, label }, ref) => {
    return (
      <div ref={ref} className={cn('flex flex-col gap-1.5', className)}>
        {label && (
          <span className="text-sm font-medium text-foreground">{label}</span>
        )}
        <div className="flex flex-wrap gap-2" role="radiogroup" aria-label={label ?? 'Pick a color'}>
          {colors.map((color) => (
            <button
              key={color}
              type="button"
              role="radio"
              aria-checked={value === color}
              aria-label={color}
              onClick={() => onChange?.(color)}
              className={cn(
                'h-7 w-7 rounded-full border-2 transition-all',
                'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
                'hover:scale-110',
                value === color
                  ? 'border-foreground ring-2 ring-ring ring-offset-2 scale-110'
                  : 'border-transparent'
              )}
              style={{ backgroundColor: color }}
            />
          ))}
        </div>
      </div>
    );
  }
);
 
ColorPicker.displayName = 'ColorPicker';

Create lib/components/ColorPicker/index.ts:

typescript
export { ColorPicker } from './ColorPicker';

The design decisions:

  • Predefined swatches, not a color wheel. Ten curated colors that look good in both light and dark mode. The colors prop lets consumers override this list.
  • Radio group semantics. The swatches use role="radiogroup" and role="radio" with aria-checked — screen readers announce it as "Pick a color, indigo, selected" rather than a grid of unlabeled buttons.
  • Controlled component. value and onChange follow the standard React controlled pattern. No internal state.
  • Visual feedback. The selected swatch gets a border and ring. All swatches scale up on hover. Focus-visible rings match the library's focus pattern.

Component 2: AlertDialog

Deleting an epic or a project is destructive. We need a confirmation dialog — "Are you sure? This cannot be undone." This is a specific pattern that comes up often enough to warrant a library component rather than ad-hoc Modal usage every time.

Create lib/components/AlertDialog/AlertDialog.tsx:

tsx
// lib/components/AlertDialog/AlertDialog.tsx
import { forwardRef, useEffect, useRef, useCallback, useImperativeHandle } from 'react';
import { createPortal } from 'react-dom';
import { cn } from '../../utils/cn';
import { Button } from '../Button';
 
type AlertDialogProps = {
  open: boolean;
  onClose: () => void;
  onConfirm: () => void;
  title: string;
  description?: string;
  confirmLabel?: string;
  cancelLabel?: string;
  intent?: 'destructive' | 'primary';
  loading?: boolean;
  className?: string;
};
 
export const AlertDialog = forwardRef<HTMLDivElement, AlertDialogProps>(
  (
    {
      open,
      onClose,
      onConfirm,
      title,
      description,
      confirmLabel = 'Confirm',
      cancelLabel = 'Cancel',
      intent = 'destructive',
      loading = false,
      className,
    },
    ref
  ) => {
    const contentRef = useRef<HTMLDivElement>(null);
    const previousActiveElement = useRef<HTMLElement | null>(null);
    const cancelRef = useRef<HTMLButtonElement>(null);
 
    useImperativeHandle(ref, () => contentRef.current as HTMLDivElement);
 
    const handleKeyDown = useCallback(
      (e: KeyboardEvent) => {
        if (e.key === 'Escape') {
          onClose();
          return;
        }
        if (e.key !== 'Tab') return;
 
        const modal = contentRef.current;
        if (!modal) return;
 
        const focusable = modal.querySelectorAll<HTMLElement>(
          'button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
        );
        const first = focusable[0];
        const last = focusable[focusable.length - 1];
 
        if (e.shiftKey && document.activeElement === first) {
          e.preventDefault();
          last?.focus();
        } else if (!e.shiftKey && document.activeElement === last) {
          e.preventDefault();
          first?.focus();
        }
      },
      [onClose]
    );
 
    useEffect(() => {
      if (!open) return;
 
      previousActiveElement.current = document.activeElement as HTMLElement;
      document.body.style.overflow = 'hidden';
      document.addEventListener('keydown', handleKeyDown);
 
      // Focus the cancel button — the safe action — not the destructive one
      requestAnimationFrame(() => {
        cancelRef.current?.focus();
      });
 
      return () => {
        document.body.style.overflow = '';
        document.removeEventListener('keydown', handleKeyDown);
        previousActiveElement.current?.focus();
      };
    }, [open, handleKeyDown]);
 
    if (!open) return null;
 
    return createPortal(
      <div className="fixed inset-0 z-50 flex items-center justify-center">
        <div
          className="fixed inset-0 bg-foreground/40 backdrop-blur-sm"
          onClick={onClose}
          aria-hidden="true"
        />
        <div
          ref={contentRef}
          role="alertdialog"
          aria-modal="true"
          aria-labelledby="alert-dialog-title"
          aria-describedby={description ? 'alert-dialog-description' : undefined}
          className={cn(
            'relative z-50 w-full max-w-md rounded-xl border border-border',
            'bg-background p-6 shadow-lg',
            className
          )}
        >
          <h2 id="alert-dialog-title" className="text-lg font-semibold text-foreground">
            {title}
          </h2>
          {description && (
            <p id="alert-dialog-description" className="mt-2 text-sm text-muted-foreground">
              {description}
            </p>
          )}
          <div className="mt-6 flex justify-end gap-3">
            <Button
              ref={cancelRef}
              intent="secondary"
              onClick={onClose}
              disabled={loading}
            >
              {cancelLabel}
            </Button>
            <Button
              intent={intent}
              onClick={onConfirm}
              disabled={loading}
            >
              {loading ? 'Deleting...' : confirmLabel}
            </Button>
          </div>
        </div>
      </div>,
      document.body
    );
  }
);
 
AlertDialog.displayName = 'AlertDialog';

Create lib/components/AlertDialog/index.ts:

typescript
export { AlertDialog } from './AlertDialog';

Key design decisions:

  • role="alertdialog" instead of role="dialog". This tells assistive technology that the dialog requires an immediate response. Screen readers will announce the content more urgently.
  • Focus lands on Cancel, not Confirm. This is a critical UX pattern for destructive dialogs. If the user presses Enter immediately (out of habit), the safe action fires. The user must explicitly tab to and activate the destructive button. This prevents accidental deletions.
  • Overlay click calls onClose, not onConfirm. Clicking outside dismisses the dialog — the safe path. The user must click the confirm button deliberately.
  • loading prop. While the delete request is in flight, both buttons are disabled and the confirm button shows a loading label. This prevents double-clicks.
  • Reuses Button from the library. The cancel button is secondary, the confirm button matches the intent prop (usually destructive).

The focus trap logic mirrors the existing Modal component. In a larger library, you would extract this into a shared hook (useFocusTrap). For now, the duplication is acceptable — two components is not worth an abstraction.

Storybook Stories

Create lib/components/ColorPicker/ColorPicker.stories.tsx:

tsx
import type { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';
import { ColorPicker } from './ColorPicker';
 
const meta: Meta<typeof ColorPicker> = {
  component: ColorPicker,
  argTypes: {
    label: { control: 'text' },
  },
};
 
export default meta;
type Story = StoryObj<typeof ColorPicker>;
 
export const Default: Story = {
  render: (args) => {
    const [color, setColor] = useState('#6366f1');
    return <ColorPicker {...args} value={color} onChange={setColor} />;
  },
};
 
export const WithLabel: Story = {
  render: (args) => {
    const [color, setColor] = useState('#10b981');
    return <ColorPicker {...args} value={color} onChange={setColor} label="Epic Color" />;
  },
};
 
export const CustomColors: Story = {
  render: (args) => {
    const [color, setColor] = useState('#000000');
    return (
      <ColorPicker
        {...args}
        value={color}
        onChange={setColor}
        label="Monochrome"
        colors={['#000000', '#333333', '#666666', '#999999', '#cccccc', '#ffffff']}
      />
    );
  },
};

Create lib/components/AlertDialog/AlertDialog.stories.tsx:

tsx
import type { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';
import { AlertDialog } from './AlertDialog';
import { Button } from '../Button';
 
const meta: Meta<typeof AlertDialog> = {
  component: AlertDialog,
  argTypes: {
    title: { control: 'text' },
    description: { control: 'text' },
    confirmLabel: { control: 'text' },
    cancelLabel: { control: 'text' },
    intent: { control: 'select', options: ['destructive', 'primary'] },
    loading: { control: 'boolean' },
  },
};
 
export default meta;
type Story = StoryObj<typeof AlertDialog>;
 
export const Destructive: Story = {
  render: (args) => {
    const [open, setOpen] = useState(false);
    return (
      <>
        <Button intent="destructive" onClick={() => setOpen(true)}>
          Delete Epic
        </Button>
        <AlertDialog
          {...args}
          open={open}
          onClose={() => setOpen(false)}
          onConfirm={() => setOpen(false)}
          title="Delete Epic"
          description="This will remove the epic and unlink all associated issues. This action cannot be undone."
          confirmLabel="Delete"
        />
      </>
    );
  },
};
 
export const NonDestructive: Story = {
  render: (args) => {
    const [open, setOpen] = useState(false);
    return (
      <>
        <Button intent="primary" onClick={() => setOpen(true)}>
          Publish Project
        </Button>
        <AlertDialog
          {...args}
          open={open}
          onClose={() => setOpen(false)}
          onConfirm={() => setOpen(false)}
          title="Publish Project"
          description="This will make the project visible to all team members."
          confirmLabel="Publish"
          intent="primary"
        />
      </>
    );
  },
};
 
export const Loading: Story = {
  render: (args) => {
    const [open, setOpen] = useState(true);
    return (
      <AlertDialog
        {...args}
        open={open}
        onClose={() => setOpen(false)}
        onConfirm={() => {}}
        title="Deleting..."
        description="Please wait while the item is being removed."
        confirmLabel="Delete"
        loading
      />
    );
  },
};

Verify they render with Storybook:

bash
npm run storybook

Exporting the New Components

Update lib/index.ts:

diff
  export { Button } from './components/Button';
  export { Input } from './components/Input';
  export { Card, CardHeader, CardContent, CardFooter } from './components/Card';
  export { Modal } from './components/Modal';
  export { Tabs, TabsList, TabsTrigger, TabsContent } from './components/Tabs';
  export { Badge } from './components/Badge';
  export { Checkbox } from './components/Checkbox';
  export { Select } from './components/Select';
  export { Textarea } from './components/Textarea';
+ export { ColorPicker } from './components/ColorPicker';
+ export { AlertDialog } from './components/AlertDialog';
 
  export { cn } from './utils/cn';

Commit and Publish

Use conventional commits so semantic-release picks up the change:

bash
git add .
git commit -m "feat: add ColorPicker and AlertDialog components
 
- ColorPicker: swatch grid with radio group semantics, customizable color list
- AlertDialog: confirmation dialog for destructive actions, focus-safe (lands on Cancel)
- Storybook stories for both components"
git push origin main

Once CI/CD completes, install the new version in the kanban app:

bash
cd path/to/solo-kanban
npm install @dxsolo/ui@latest

Server Actions

Part 5 needs more server actions than any previous part. We need CRUD for projects, CRUD for epics, and issue creation. Let's build these first so the UI has something to call.

Project Actions

Create src/actions/project-actions.ts:

typescript
"use server";
 
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
 
export async function getProjects() {
  return prisma.project.findMany({
    orderBy: { createdAt: "asc" },
    select: {
      id: true,
      name: true,
    },
  });
}
 
export async function createProject(name: string, description?: string) {
  const project = await prisma.project.create({
    data: { name, description },
  });
 
  redirect(`/projects/${project.id}/board`);
}
 
export async function deleteProject(projectId: string) {
  await prisma.project.delete({
    where: { id: projectId },
  });
 
  // Redirect to first remaining project, or home
  const next = await prisma.project.findFirst({
    orderBy: { createdAt: "asc" },
  });
 
  redirect(next ? `/projects/${next.id}/board` : "/");
}

getProjects returns only id and name — that is all the project switcher needs. createProject creates the project and redirects to its board. deleteProject removes the project and all its data (the Prisma schema has onDelete: Cascade on issues and epics) then redirects to the next available project.

Epic Actions

Create src/actions/epic-actions.ts:

typescript
"use server";
 
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
 
export async function createEpic(projectId: string, name: string, color: string) {
  const epic = await prisma.epic.create({
    data: { name, color, projectId },
  });
 
  revalidatePath(`/projects/${projectId}/board`);
  return epic;
}
 
export async function updateEpic(epicId: string, data: { name?: string; color?: string }) {
  const epic = await prisma.epic.update({
    where: { id: epicId },
    data,
  });
 
  revalidatePath(`/projects/${epic.projectId}/board`);
  return epic;
}
 
export async function deleteEpic(epicId: string) {
  const epic = await prisma.epic.delete({
    where: { id: epicId },
  });
 
  // Issues linked to this epic get epicId set to null (onDelete: SetNull in schema)
  revalidatePath(`/projects/${epic.projectId}/board`);
}

Each action calls revalidatePath after the mutation. This tells Next.js to re-run the server component for the board page, which re-fetches the project data including epics and issues. The sidebar and board will reflect the changes without a full page reload.

Create Issue Action

Create src/actions/create-issue.ts:

typescript
"use server";
 
import { prisma } from "@/lib/prisma";
import type { IssueStatus } from "@prisma/client";
import { revalidatePath } from "next/cache";
 
export async function createIssue(
  projectId: string,
  title: string,
  status: IssueStatus,
  epicId?: string
) {
  // Place the new issue at the end of the target column
  const maxOrder = await prisma.issue.aggregate({
    where: { projectId, status },
    _max: { order: true },
  });
 
  const order = (maxOrder._max.order ?? -1) + 1;
 
  const issue = await prisma.issue.create({
    data: {
      title,
      status,
      order,
      projectId,
      ...(epicId && { epicId }),
    },
    include: {
      epic: true,
      subtasks: true,
    },
  });
 
  revalidatePath(`/projects/${projectId}/board`);
  return issue;
}

This queries for the highest order value in the target column and places the new issue after it. The optional epicId lets us tag an issue with an epic at creation time. The include returns the full IssueWithRelations shape so the board can add it to state immediately.


Filtering with URL Search Params

Before building the UI, let's decide how filtering works. We will use URL search params:

plaintext
/projects/abc123/board              → no filter, show all issues
/projects/abc123/board?epic=def456  → filter by epic

This approach has clear advantages:

  1. Shareable. Copy the URL and the filter comes with it.
  2. Bookmarkable. Save a filtered view for quick access.
  3. Server-compatible. The server component reads searchParams and can filter the database query directly — fewer issues sent to the client.
  4. No client state to sync. The URL is the single source of truth for the active filter.

Updating the Board Page

The board page needs to read the epic search param and filter issues accordingly:

diff
  // src/app/projects/[projectId]/board/page.tsx
 
  interface BoardPageProps {
    params: Promise<{ projectId: string }>;
+   searchParams: Promise<{ epic?: string }>;
  }
 
- export default async function BoardPage({ params }: BoardPageProps) {
+ export default async function BoardPage({ params, searchParams }: BoardPageProps) {
    const { projectId } = await params;
+   const { epic: epicFilter } = await searchParams;

Filter the issues in the query:

diff
    const project = await prisma.project.findUnique({
      where: { id: projectId },
      include: {
        epics: true,
        issues: {
+         where: epicFilter ? { epicId: epicFilter } : undefined,
          include: {
            epic: true,
            subtasks: true,
          },
          orderBy: { order: "asc" },
        },
        _count: {
          select: { epics: true, issues: true },
        },
      },
    });

The where clause is conditional. If epicFilter is set, only issues belonging to that epic are returned. If not, all issues come back. Prisma ignores undefined where clauses, so this is clean.

Pass the filter state and project list to the sidebar:

diff
+ import { getProjects } from "@/actions/project-actions";
 
  export default async function BoardPage({ params, searchParams }: BoardPageProps) {
    const { projectId } = await params;
    const { epic: epicFilter } = await searchParams;
+   const projects = await getProjects();
 
    // ... project query ...
 
    return (
      <AppShell
        sidebar={
          <Sidebar
            projectName={project.name}
-           epicCount={project._count.epics}
-           issueCount={project._count.issues}
+           projectId={projectId}
+           projects={projects}
+           epics={project.epics}
+           epicFilter={epicFilter ?? null}
          />
        }
      >
        <Board
          key={JSON.stringify(project.issues.map(i => i.id + i.status + i.order))}
          issues={project.issues}
+         projectId={projectId}
        />
      </AppShell>
    );
  }

Here is the full updated file:

tsx
// src/app/projects/[projectId]/board/page.tsx
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import { AppShell } from "@/components/layout/AppShell";
import { Sidebar } from "@/components/layout/Sidebar";
import { Board } from "@/components/board/Board";
import { getProjects } from "@/actions/project-actions";
 
interface BoardPageProps {
  params: Promise<{ projectId: string }>;
  searchParams: Promise<{ epic?: string }>;
}
 
export default async function BoardPage({ params, searchParams }: BoardPageProps) {
  const { projectId } = await params;
  const { epic: epicFilter } = await searchParams;
  const projects = await getProjects();
 
  const project = await prisma.project.findUnique({
    where: { id: projectId },
    include: {
      epics: true,
      issues: {
        where: epicFilter ? { epicId: epicFilter } : undefined,
        include: {
          epic: true,
          subtasks: true,
        },
        orderBy: { order: "asc" },
      },
      _count: {
        select: { epics: true, issues: true },
      },
    },
  });
 
  if (!project) notFound();
 
  return (
    <AppShell
      sidebar={
        <Sidebar
          projectId={projectId}
          projects={projects}
          epics={project.epics}
          epicFilter={epicFilter ?? null}
        />
      }
    >
      <Board
        key={JSON.stringify(project.issues.map((i) => i.id + i.status + i.order))}
        issues={project.issues}
        projectId={projectId}
      />
    </AppShell>
  );
}

Rebuilding the Sidebar

The sidebar goes from a static display to an interactive control center. It needs to handle project switching, epic management, and filtering. Let's rebuild it.

Replace the contents of src/components/layout/Sidebar.tsx:

tsx
"use client";
 
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import type { Epic } from "@prisma/client";
import { Button, Select, Modal, Input, ColorPicker, AlertDialog } from "@dxsolo/ui";
import { createProject } from "@/actions/project-actions";
import { createEpic, updateEpic, deleteEpic } from "@/actions/epic-actions";
 
interface SidebarProps {
  projectId: string;
  projects: { id: string; name: string }[];
  epics: Epic[];
  epicFilter: string | null;
}
 
export function Sidebar({ projectId, projects, epics, epicFilter }: SidebarProps) {
  const router = useRouter();
  const searchParams = useSearchParams();
 
  // Project creation state
  const [showCreateProject, setShowCreateProject] = useState(false);
  const [newProjectName, setNewProjectName] = useState("");
  const [newProjectDescription, setNewProjectDescription] = useState("");
  const [isCreatingProject, setIsCreatingProject] = useState(false);
 
  // Epic creation/editing state
  const [showEpicModal, setShowEpicModal] = useState(false);
  const [editingEpic, setEditingEpic] = useState<Epic | null>(null);
  const [epicName, setEpicName] = useState("");
  const [epicColor, setEpicColor] = useState("#6366f1");
  const [isSavingEpic, setIsSavingEpic] = useState(false);
 
  // Epic deletion state
  const [deletingEpic, setDeletingEpic] = useState<Epic | null>(null);
  const [isDeletingEpic, setIsDeletingEpic] = useState(false);
 
  // --- Project handlers ---
 
  const handleProjectSwitch = (newProjectId: string) => {
    router.push(`/projects/${newProjectId}/board`);
  };
 
  const handleCreateProject = async () => {
    const trimmed = newProjectName.trim();
    if (!trimmed) return;
 
    setIsCreatingProject(true);
    try {
      await createProject(trimmed, newProjectDescription.trim() || undefined);
      // createProject redirects, so this state cleanup is for safety
      setShowCreateProject(false);
      setNewProjectName("");
      setNewProjectDescription("");
    } catch (error) {
      console.error("Failed to create project:", error);
    } finally {
      setIsCreatingProject(false);
    }
  };
 
  // --- Epic filter handler ---
 
  const toggleEpicFilter = (epicId: string) => {
    const params = new URLSearchParams(searchParams.toString());
 
    if (epicFilter === epicId) {
      // Already filtering by this epic — remove filter
      params.delete("epic");
    } else {
      params.set("epic", epicId);
    }
 
    router.push(`/projects/${projectId}/board?${params.toString()}`);
  };
 
  const clearFilter = () => {
    router.push(`/projects/${projectId}/board`);
  };
 
  // --- Epic CRUD handlers ---
 
  const openCreateEpic = () => {
    setEditingEpic(null);
    setEpicName("");
    setEpicColor("#6366f1");
    setShowEpicModal(true);
  };
 
  const openEditEpic = (epic: Epic) => {
    setEditingEpic(epic);
    setEpicName(epic.name);
    setEpicColor(epic.color);
    setShowEpicModal(true);
  };
 
  const handleSaveEpic = async () => {
    const trimmed = epicName.trim();
    if (!trimmed) return;
 
    setIsSavingEpic(true);
    try {
      if (editingEpic) {
        await updateEpic(editingEpic.id, { name: trimmed, color: epicColor });
      } else {
        await createEpic(projectId, trimmed, epicColor);
      }
      setShowEpicModal(false);
      router.refresh();
    } catch (error) {
      console.error("Failed to save epic:", error);
    } finally {
      setIsSavingEpic(false);
    }
  };
 
  const handleDeleteEpic = async () => {
    if (!deletingEpic) return;
 
    setIsDeletingEpic(true);
    try {
      await deleteEpic(deletingEpic.id);
 
      // If we were filtering by this epic, clear the filter
      if (epicFilter === deletingEpic.id) {
        router.push(`/projects/${projectId}/board`);
      } else {
        router.refresh();
      }
 
      setDeletingEpic(null);
    } catch (error) {
      console.error("Failed to delete epic:", error);
    } finally {
      setIsDeletingEpic(false);
    }
  };
 
  return (
    <div className="p-4 flex flex-col gap-4 h-full">
      {/* Project switcher */}
      <div className="flex flex-col gap-2">
        <Select
          value={projectId}
          onChange={(e) => handleProjectSwitch(e.target.value)}
          label="Project"
        >
          {projects.map((p) => (
            <option key={p.id} value={p.id}>
              {p.name}
            </option>
          ))}
        </Select>
        <Button
          intent="ghost"
          size="sm"
          className="justify-start w-full"
          onClick={() => setShowCreateProject(true)}
        >
          + New Project
        </Button>
      </div>
 
      {/* Navigation */}
      <nav className="flex flex-col gap-1">
        <Button intent="ghost" className="justify-start w-full font-medium">
          Board
        </Button>
      </nav>
 
      {/* Epics section */}
      <div className="border-t border-border pt-4 flex-1 min-h-0 flex flex-col">
        <div className="flex items-center justify-between mb-2">
          <h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
            Epics
          </h2>
          <Button intent="ghost" size="sm" onClick={openCreateEpic}>
            +
          </Button>
        </div>
 
        {/* Active filter indicator */}
        {epicFilter && (
          <button
            onClick={clearFilter}
            className="text-xs text-primary hover:text-primary-hover mb-2 text-left"
          >
            ✕ Clear filter
          </button>
        )}
 
        {/* Epic list */}
        <div className="flex flex-col gap-0.5 flex-1 overflow-y-auto">
          {epics.map((epic) => (
            <div
              key={epic.id}
              className={`group flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer transition-colors ${
                epicFilter === epic.id
                  ? "bg-primary/10 text-primary"
                  : "hover:bg-muted text-foreground"
              }`}
            >
              {/* Color dot — click to filter */}
              <button
                onClick={() => toggleEpicFilter(epic.id)}
                className="flex items-center gap-2 flex-1 min-w-0 text-left"
              >
                <span
                  className="h-3 w-3 rounded-full shrink-0"
                  style={{ backgroundColor: epic.color }}
                />
                <span className="text-sm truncate">{epic.name}</span>
              </button>
 
              {/* Edit/delete buttons — visible on hover */}
              <div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
                <button
                  onClick={() => openEditEpic(epic)}
                  className="text-xs text-muted-foreground hover:text-foreground p-0.5"
                  aria-label={`Edit ${epic.name}`}
                >
                  ✎
                </button>
                <button
                  onClick={() => setDeletingEpic(epic)}
                  className="text-xs text-muted-foreground hover:text-destructive p-0.5"
                  aria-label={`Delete ${epic.name}`}
                >
                  ✕
                </button>
              </div>
            </div>
          ))}
 
          {epics.length === 0 && (
            <p className="text-sm text-muted-foreground px-2">
              No epics yet. Create one to organize your issues.
            </p>
          )}
        </div>
      </div>
 
      {/* Create Project modal */}
      <Modal
        open={showCreateProject}
        onClose={() => setShowCreateProject(false)}
        title="New Project"
        description="Create a new project to organize your work."
      >
        <div className="flex flex-col gap-4">
          <Input
            label="Name"
            placeholder="My Project"
            value={newProjectName}
            onChange={(e) => setNewProjectName(e.target.value)}
            onKeyDown={(e) => e.key === "Enter" && handleCreateProject()}
          />
          <Input
            label="Description"
            placeholder="Optional description"
            value={newProjectDescription}
            onChange={(e) => setNewProjectDescription(e.target.value)}
          />
          <div className="flex justify-end gap-3">
            <Button
              intent="secondary"
              onClick={() => setShowCreateProject(false)}
            >
              Cancel
            </Button>
            <Button
              intent="primary"
              onClick={handleCreateProject}
              disabled={isCreatingProject || !newProjectName.trim()}
            >
              {isCreatingProject ? "Creating..." : "Create Project"}
            </Button>
          </div>
        </div>
      </Modal>
 
      {/* Create/Edit Epic modal */}
      <Modal
        open={showEpicModal}
        onClose={() => setShowEpicModal(false)}
        title={editingEpic ? "Edit Epic" : "New Epic"}
      >
        <div className="flex flex-col gap-4">
          <Input
            label="Name"
            placeholder="Feature Area"
            value={epicName}
            onChange={(e) => setEpicName(e.target.value)}
            onKeyDown={(e) => e.key === "Enter" && handleSaveEpic()}
          />
          <ColorPicker
            label="Color"
            value={epicColor}
            onChange={setEpicColor}
          />
          {/* Preview */}
          <div className="flex items-center gap-2">
            <span className="text-sm text-muted-foreground">Preview:</span>
            <span
              className="text-xs px-1.5 py-0.5 rounded inline-flex items-center gap-1"
              style={{
                backgroundColor: `${epicColor}20`,
                color: epicColor,
              }}
            >
              <span
                className="h-2 w-2 rounded-full"
                style={{ backgroundColor: epicColor }}
              />
              {epicName || "Epic Name"}
            </span>
          </div>
          <div className="flex justify-end gap-3">
            <Button
              intent="secondary"
              onClick={() => setShowEpicModal(false)}
            >
              Cancel
            </Button>
            <Button
              intent="primary"
              onClick={handleSaveEpic}
              disabled={isSavingEpic || !epicName.trim()}
            >
              {isSavingEpic ? "Saving..." : editingEpic ? "Save Changes" : "Create Epic"}
            </Button>
          </div>
        </div>
      </Modal>
 
      {/* Delete Epic confirmation */}
      <AlertDialog
        open={!!deletingEpic}
        onClose={() => setDeletingEpic(null)}
        onConfirm={handleDeleteEpic}
        title="Delete Epic"
        description={`This will delete "${deletingEpic?.name}" and remove the epic label from all associated issues. The issues themselves will not be deleted. This action cannot be undone.`}
        confirmLabel="Delete"
        loading={isDeletingEpic}
      />
    </div>
  );
}

That is a big file. Let's break down what changed and why.

Project Switcher

The Select component replaces the static project name heading. It shows all projects as options and navigates on change:

tsx
<Select
  value={projectId}
  onChange={(e) => handleProjectSwitch(e.target.value)}
  label="Project"
>
  {projects.map((p) => (
    <option key={p.id} value={p.id}>{p.name}</option>
  ))}
</Select>

Changing the select calls router.push() to navigate to the new project's board. The page re-renders with the new project's data. No client state to sync — the URL is the source of truth.

The "+ New Project" button opens a modal with a name and description field. On submit, createProject creates the project in the database and redirects to its board.

Epic List with Filtering

Each epic renders as a row with a color dot, name, and hover-visible edit/delete buttons:

tsx
<button onClick={() => toggleEpicFilter(epic.id)} className="flex items-center gap-2 flex-1">
  <span className="h-3 w-3 rounded-full" style={{ backgroundColor: epic.color }} />
  <span className="text-sm truncate">{epic.name}</span>
</button>

Clicking the epic name toggles the board filter. The toggleEpicFilter function manipulates URL search params:

tsx
const toggleEpicFilter = (epicId: string) => {
  const params = new URLSearchParams(searchParams.toString());
  if (epicFilter === epicId) {
    params.delete("epic");  // toggle off
  } else {
    params.set("epic", epicId);  // toggle on
  }
  router.push(`/projects/${projectId}/board?${params.toString()}`);
};

When the URL changes, the server component re-runs its Prisma query with the where: { epicId: epicFilter } clause. Only matching issues come back. The board re-renders with the filtered set.

The active epic gets a highlighted background (bg-primary/10). A "Clear filter" link appears above the epic list when a filter is active.

Epic CRUD

The create and edit forms share a single modal. When creating, editingEpic is null and the fields start empty. When editing, editingEpic is set and the fields are pre-populated:

tsx
const openEditEpic = (epic: Epic) => {
  setEditingEpic(epic);
  setEpicName(epic.name);
  setEpicColor(epic.color);
  setShowEpicModal(true);
};

The ColorPicker component from @dxsolo/ui renders ten color swatches. Below it, a live preview shows how the epic label will look with the chosen name and color.

Deleting uses the AlertDialog component. The description tells the user what will happen: the epic is removed, issues are unlinked (not deleted), and the action cannot be undone. If the user was filtering by the deleted epic, the filter is cleared.


Creating Issues from the Board

Until now, issues only existed via the seed script. Let's add a "+" button to each column header that opens a quick-create form.

The Create Issue Form

Create src/components/board/CreateIssueForm.tsx:

tsx
"use client";
 
import { useState, useRef, useEffect } from "react";
import { Input, Button, Select } from "@dxsolo/ui";
import type { IssueStatus, Epic } from "@prisma/client";
import { createIssue } from "@/actions/create-issue";
import type { IssueWithRelations } from "@/types";
 
interface CreateIssueFormProps {
  projectId: string;
  status: IssueStatus;
  epics: Epic[];
  onCreated: (issue: IssueWithRelations) => void;
  onCancel: () => void;
}
 
export function CreateIssueForm({
  projectId,
  status,
  epics,
  onCreated,
  onCancel,
}: CreateIssueFormProps) {
  const [title, setTitle] = useState("");
  const [epicId, setEpicId] = useState("");
  const [isCreating, setIsCreating] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);
 
  useEffect(() => {
    inputRef.current?.focus();
  }, []);
 
  const handleSubmit = async () => {
    const trimmed = title.trim();
    if (!trimmed) return;
 
    setIsCreating(true);
    try {
      const issue = await createIssue(projectId, trimmed, status, epicId || undefined);
      onCreated(issue);
      setTitle("");
      setEpicId("");
    } catch (error) {
      console.error("Failed to create issue:", error);
    } finally {
      setIsCreating(false);
    }
  };
 
  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === "Enter") {
      e.preventDefault();
      handleSubmit();
    }
    if (e.key === "Escape") {
      onCancel();
    }
  };
 
  return (
    <div className="flex flex-col gap-2 p-2 rounded-lg border border-border bg-background">
      <Input
        ref={inputRef}
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        onKeyDown={handleKeyDown}
        placeholder="Issue title..."
        disabled={isCreating}
      />
      {epics.length > 0 && (
        <Select
          value={epicId}
          onChange={(e) => setEpicId(e.target.value)}
          disabled={isCreating}
        >
          <option value="">No epic</option>
          {epics.map((epic) => (
            <option key={epic.id} value={epic.id}>
              {epic.name}
            </option>
          ))}
        </Select>
      )}
      <div className="flex justify-end gap-2">
        <Button intent="ghost" size="sm" onClick={onCancel} disabled={isCreating}>
          Cancel
        </Button>
        <Button
          intent="primary"
          size="sm"
          onClick={handleSubmit}
          disabled={isCreating || !title.trim()}
        >
          {isCreating ? "Adding..." : "Add"}
        </Button>
      </div>
    </div>
  );
}

This is an inline form — not a modal. It appears at the bottom of the column when the user clicks "+". Press Enter to create, Escape to cancel. The input auto-focuses on mount. After creation, the form clears and stays open for batch entry. If the project has epics, a dropdown lets the user assign one at creation time — defaulting to "No epic" so it stays optional.

Adding the "+" Button to Columns

Update Column.tsx to accept the new props and render the create form:

diff
+ import type { Epic } from "@prisma/client";
+ import { CreateIssueForm } from "./CreateIssueForm";
 
  interface ColumnProps {
    status: IssueStatus;
    label: string;
    issues: IssueWithRelations[];
    onMoveIssue: (issueId: string, newStatus: IssueStatus, newOrder: number) => void;
    onSelectIssue: (issue: IssueWithRelations) => void;
+   projectId: string;
+   epics: Epic[];
+   onIssueCreated: (issue: IssueWithRelations) => void;
  }
diff
- export function Column({ status, label, issues, onMoveIssue, onSelectIssue }: ColumnProps) {
+ export function Column({ status, label, issues, onMoveIssue, onSelectIssue, projectId, epics, onIssueCreated }: ColumnProps) {
    const ref = useRef<HTMLDivElement>(null);
    const [isDraggedOver, setIsDraggedOver] = useState(false);
+   const [showCreateForm, setShowCreateForm] = useState(false);

Add the "+" button to the column header:

diff
      <div className="flex items-center justify-between px-3 py-2 border-b border-gray-200">
        <div className="flex items-center gap-2">
          <span
            className={`text-xs font-semibold px-2 py-0.5 rounded-full ${statusColors[status]}`}
          >
            {label}
          </span>
          <span className="text-xs text-gray-400">{issues.length}</span>
        </div>
+       <button
+         onClick={() => setShowCreateForm(true)}
+         className="text-gray-400 hover:text-gray-600 hover:bg-gray-200 rounded text-lg leading-none w-6 h-6 flex items-center justify-center transition-colors"
+         aria-label={`Add issue to ${label}`}
+       >
+         +
+       </button>
      </div>

Render the form at the bottom of the card list:

diff
      <div className="flex-1 overflow-y-auto p-2 flex flex-col gap-2 @container">
        {issues.map((issue) => (
          <IssueCard key={issue.id} issue={issue} onMoveIssue={onMoveIssue} onSelect={onSelectIssue} />
        ))}
 
        {issues.length === 0 && !showCreateForm && (
          <div
            className={`flex items-center justify-center h-32 border-2 border-dashed rounded-lg ${
              isDraggedOver
                ? "border-blue-300 bg-blue-50"
                : "border-gray-200"
            }`}
          >
            <p className="text-xs text-gray-400 text-center px-4">
              {emptyMessages[status]}
            </p>
          </div>
        )}
+
+       {showCreateForm && (
+         <CreateIssueForm
+           projectId={projectId}
+           status={status}
+           epics={epics}
+           onCreated={(issue) => {
+             onIssueCreated(issue);
+             setShowCreateForm(false);
+           }}
+           onCancel={() => setShowCreateForm(false)}
+         />
+       )}
      </div>

Notice the empty state now checks !showCreateForm — if the create form is visible in an empty column, we hide the "No issues" placeholder.

Updating the Board

The Board needs to pass projectId and handle the onIssueCreated callback:

diff
+ import type { Epic } from "@prisma/client";
 
  interface BoardProps {
    issues: IssueWithRelations[];
+   projectId: string;
+   epics: Epic[];
  }
 
- export function Board({ issues: initialIssues }: BoardProps) {
+ export function Board({ issues: initialIssues, projectId, epics }: BoardProps) {

Add the handler:

tsx
const handleIssueCreated = useCallback((issue: IssueWithRelations) => {
  setIssues((prev) => [...prev, issue]);
}, []);

Pass the new props to each Column:

diff
  <Column
    key={col.status}
    status={col.status}
    label={col.label}
    issues={col.issues}
    onMoveIssue={handleMoveIssue}
    onSelectIssue={setSelectedIssue}
+   projectId={projectId}
+   epics={epics}
+   onIssueCreated={handleIssueCreated}
  />

Here is the full updated Board.tsx:

tsx
"use client";
 
import { useState, useCallback } from "react";
import { IssueStatus } from "@prisma/client";
import type { Epic } from "@prisma/client";
import { Column } from "./Column";
import type { IssueWithRelations } from "@/types";
import { moveIssue } from "@/actions/move-issue";
import { IssueDetailModal } from "./IssueDetailModal";
import { getIssue } from "@/actions/get-issue";
 
const COLUMNS: { status: IssueStatus; label: string }[] = [
  { status: "OPEN", label: "Open" },
  { status: "IN_PROGRESS", label: "In Progress" },
  { status: "REVIEW", label: "Review" },
  { status: "DONE", label: "Done" },
];
 
interface BoardProps {
  issues: IssueWithRelations[];
  projectId: string;
  epics: Epic[];
}
 
export function Board({ issues: initialIssues, projectId, epics }: BoardProps) {
  const [issues, setIssues] = useState(initialIssues);
  const [isMoving, setIsMoving] = useState(false);
  const [selectedIssue, setSelectedIssue] = useState<IssueWithRelations | null>(null);
 
  const handleMoveIssue = useCallback(
    async (issueId: string, newStatus: IssueStatus, newOrder: number) => {
      if (isMoving) return;
      setIsMoving(true);
 
      const previousIssues = issues;
 
      setIssues((prev) => {
        const issue = prev.find((i) => i.id === issueId);
        if (!issue) return prev;
 
        const without = prev.filter((i) => i.id !== issueId);
        const targetColumnIssues = without
          .filter((i) => i.status === newStatus)
          .sort((a, b) => a.order - b.order);
 
        const clampedOrder = Math.min(newOrder, targetColumnIssues.length);
 
        targetColumnIssues.splice(clampedOrder, 0, {
          ...issue,
          status: newStatus,
          order: clampedOrder,
        });
 
        const reindexed = targetColumnIssues.map((item, index) => ({
          ...item,
          order: index,
        }));
 
        const otherIssues = without.filter((i) => i.status !== newStatus);
        return [...otherIssues, ...reindexed];
      });
 
      try {
        await moveIssue(issueId, newStatus, newOrder);
      } catch (error) {
        console.error("Failed to move issue:", error);
        setIssues(previousIssues);
      } finally {
        setIsMoving(false);
      }
    },
    [issues, isMoving]
  );
 
  const handleIssueCreated = useCallback((issue: IssueWithRelations) => {
    setIssues((prev) => [...prev, issue]);
  }, []);
 
  const handleCloseDetail = useCallback(async () => {
    if (!selectedIssue) return;
 
    try {
      const fresh = await getIssue(selectedIssue.id);
      if (fresh) {
        setIssues((prev) => prev.map((i) => (i.id === fresh.id ? fresh : i)));
      }
    } catch (error) {
      console.error("Failed to refresh issue:", error);
    }
 
    setSelectedIssue(null);
  }, [selectedIssue]);
 
  const issuesByStatus = COLUMNS.map((col) => ({
    ...col,
    issues: issues
      .filter((issue) => issue.status === col.status)
      .sort((a, b) => a.order - b.order),
  }));
 
  return (
    <div className="h-full p-4 overflow-x-auto">
      <div className="grid grid-cols-4 gap-4 h-full min-w-[800px]">
        {issuesByStatus.map((col) => (
          <Column
            key={col.status}
            status={col.status}
            label={col.label}
            issues={col.issues}
            onMoveIssue={handleMoveIssue}
            onSelectIssue={setSelectedIssue}
            projectId={projectId}
            epics={epics}
            onIssueCreated={handleIssueCreated}
          />
        ))}
      </div>
      {selectedIssue && (
        <IssueDetailModal
          issue={issues.find((i) => i.id === selectedIssue.id) ?? selectedIssue}
          onClose={handleCloseDetail}
          onUpdate={(updated) => {
            setIssues((prev) => prev.map((i) => (i.id === updated.id ? updated : i)));
            setSelectedIssue(updated);
          }}
        />
      )}
    </div>
  );
}

Finally, update the board page to pass epics to the Board component. In src/app/projects/[projectId]/board/page.tsx:

diff
      <Board
        key={JSON.stringify(project.issues.map((i) => i.id + i.status + i.order))}
        issues={project.issues}
        projectId={projectId}
+       epics={project.epics}
      />

The epics are already fetched in the page's Prisma query (we included epics: true earlier). Now they flow from the server through Board → Column → CreateIssueForm.


Updating the Home Page

The home page currently shows a "No projects yet" message with no way to create a project. Let's add a create button:

tsx
// src/app/page.tsx
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import { CreateFirstProject } from "@/components/layout/CreateFirstProject";
 
export default async function Home() {
  const project = await prisma.project.findFirst({
    orderBy: { createdAt: "asc" },
  });
 
  if (project) {
    redirect(`/projects/${project.id}/board`);
  }
 
  return (
    <main className="flex items-center justify-center h-screen">
      <CreateFirstProject />
    </main>
  );
}

Create src/components/layout/CreateFirstProject.tsx:

tsx
"use client";
 
import { useState } from "react";
import { Button, Input, Card, CardContent } from "@dxsolo/ui";
import { createProject } from "@/actions/project-actions";
 
export function CreateFirstProject() {
  const [name, setName] = useState("");
  const [isCreating, setIsCreating] = useState(false);
 
  const handleCreate = async () => {
    const trimmed = name.trim();
    if (!trimmed) return;
 
    setIsCreating(true);
    try {
      await createProject(trimmed);
    } catch (error) {
      console.error("Failed to create project:", error);
      setIsCreating(false);
    }
  };
 
  return (
    <Card className="w-full max-w-md">
      <CardContent className="flex flex-col gap-4 p-6">
        <div>
          <h1 className="text-lg font-semibold text-foreground">Welcome to Solo Kanban</h1>
          <p className="text-sm text-muted-foreground mt-1">
            Create your first project to get started.
          </p>
        </div>
        <Input
          label="Project Name"
          placeholder="My Project"
          value={name}
          onChange={(e) => setName(e.target.value)}
          onKeyDown={(e) => e.key === "Enter" && handleCreate()}
        />
        <Button
          intent="primary"
          onClick={handleCreate}
          disabled={isCreating || !name.trim()}
          className="w-full"
        >
          {isCreating ? "Creating..." : "Create Project"}
        </Button>
      </CardContent>
    </Card>
  );
}

A clean welcome screen: a card with a text input and a button. Enter a name, press Enter or click Create, and you are on your new project's board.


Run It

Start the dev server:

bash
npm run dev

Visit http://localhost:3000. You should see:

Project switcher. The sidebar shows a dropdown with the current project. Open it to see all projects (just "Solo Kanban" from the seed).

Epic list. Below the project switcher, the epics section shows three epics: Project Setup (indigo), Board UI (amber), and Drag and Drop (emerald). Each has a color dot and name.

Try these interactions:

  • Filter by epic. Click "Board UI" in the sidebar. The board now shows only issues linked to the Board UI epic. The URL updates to ?epic=.... The selected epic is highlighted. Click it again to clear the filter, or click "Clear filter".
  • Create an epic. Click the "+" button next to the Epics heading. Enter a name, pick a color, see the preview. Click Create Epic. It appears in the list.
  • Edit an epic. Hover over an epic in the sidebar. Click the pencil icon. Change the name or color. Save.
  • Delete an epic. Hover over an epic, click the ✕. A confirmation dialog appears with the AlertDialog component. The cancel button is focused by default. Click Delete to confirm — the epic is removed and its issues lose their epic label.
  • Create a project. Click "+ New Project" below the project switcher. Enter a name and optional description. Click Create. You are redirected to the new empty project's board.
  • Switch projects. Use the project dropdown to switch back to "Solo Kanban".
  • Create an issue. Click the "+" button in any column header. Type a title and press Enter. The issue appears in the column. Click it to open the detail modal and add a description, subtasks, or change priority.

Catching a Focus Bug

Try creating a new project again. Click "+ New Project", type a name in the Name field — that works fine. Now click the Description field and start typing. Focus jumps back to the Name field on the first keystroke. Every character you type in Description yanks you back to Name. The modal is unusable for its second input.

This is a real bug, and understanding why it happens teaches something important about React effects and reference identity.

The cause. Open Modal.tsx in the component library. The modal has a useEffect that runs when the modal opens — it adds a keydown listener for Escape and Tab, locks scroll, and focuses the first focusable element:

tsx
useEffect(() => {
  if (!open) return;
 
  previousActiveElement.current = document.activeElement as HTMLElement;
  document.body.style.overflow = 'hidden';
  document.addEventListener('keydown', handleKeyDown);
 
  requestAnimationFrame(() => {
    const focusable = contentRef.current?.querySelectorAll<HTMLElement>(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    focusable?.[0]?.focus();
  });
 
  return () => {
    document.body.style.overflow = '';
    document.removeEventListener('keydown', handleKeyDown);
    previousActiveElement.current?.focus();
  };
}, [open, handleKeyDown]);

The dependency array is [open, handleKeyDown]. The handleKeyDown callback is wrapped in useCallback with [onClose] as its dependency. And onClose is an inline arrow function in the Sidebar:

tsx
<Modal
  open={showCreateProject}
  onClose={() => setShowCreateProject(false)}
  ...
>

Here is the chain:

  1. You type in the Description input.
  2. setNewProjectDescription causes the Sidebar to re-render.
  3. The re-render creates a new onClose arrow function (same logic, new reference).
  4. Inside Modal, handleKeyDown depends on onClose, so useCallback returns a new function.
  5. The useEffect depends on handleKeyDown, so it re-runs.
  6. The effect calls requestAnimationFrame → focuses the first focusable element → the Name input.
  7. Focus leaves Description. You are back in Name.

The effect is designed to run once when the modal opens, but an unstable function reference makes it fire on every render.

The fix. Split the effect in two. One effect handles initial focus and scroll lock — it depends only on open. A separate effect manages the keydown listener — it can safely depend on handleKeyDown without triggering focus side effects.

In Modal.tsx, replace the single useEffect with:

tsx
// Focus the first focusable element only when the modal opens
useEffect(() => {
  if (!open) return;
 
  previousActiveElement.current = document.activeElement as HTMLElement;
  document.body.style.overflow = 'hidden';
 
  requestAnimationFrame(() => {
    const focusable = contentRef.current?.querySelectorAll<HTMLElement>(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    focusable?.[0]?.focus();
  });
 
  return () => {
    document.body.style.overflow = '';
    previousActiveElement.current?.focus();
  };
}, [open]);
 
// Keydown listener in a separate effect so it doesn't re-trigger focus
useEffect(() => {
  if (!open) return;
 
  document.addEventListener('keydown', handleKeyDown);
  return () => {
    document.removeEventListener('keydown', handleKeyDown);
  };
}, [open, handleKeyDown]);

Now the focus logic runs exactly once when open flips to true. The keydown listener still updates when handleKeyDown changes (so Escape always calls the latest onClose), but that no longer drags focus along with it. Try the Description field again — it holds focus as expected.


Handling Edge Cases

Filtering and Drag-and-Drop

When the board is filtered by epic, dragging still works — but there is a subtlety. If you drag a card between columns while filtered, the move persists correctly because we are changing the card's status, not its epic. The card stays visible after the move because it still matches the filter.

But what about cards that are hidden by the filter? They still exist in the database. When you clear the filter, they reappear in their correct columns and positions. The filter is purely a view concern — it does not affect the underlying data.

Creating Issues While Filtered

When you create an issue while an epic filter is active, the create form's epic dropdown defaults to "No epic" — it does not automatically select the filtered epic. If the user creates an issue without choosing an epic, it will not appear on the filtered board immediately because it does not match the filter. Clear the filter and it appears in the expected column.

This default is intentional. Automatically pre-selecting the active filter's epic would be convenient in some cases but surprising in others — the user might not want every new issue tagged with the current filter's epic. If you prefer automatic pre-selection, you could pass the epicFilter through to the CreateIssueForm and use it as the initial epicId state.

Deleting a Filtered Epic

If the user deletes an epic while the board is filtered by that epic, the filter becomes invalid. The sidebar handles this by checking if (epicFilter === deletingEpic.id) and clearing the filter before refreshing. The board falls back to showing all issues.

Project Deletion

We included a deleteProject server action but did not add a UI for it in this part. Project deletion is a high-risk action — it removes all epics, issues, and subtasks via cascade. A simple "Delete" button in the sidebar is too dangerous. In Part 6, we will add a project settings page with a danger zone for this.


Ticket Numbers and Done State

Two small additions that make the board feel more like a real issue tracker: giving every issue a visible ticket number, and visually striking through issues that have been completed.

Adding a Number Field to the Schema

Each issue needs a sequential number scoped to its project — issue #1 in Project A is independent of issue #1 in Project B. Add a number field to the Issue model in prisma/schema.prisma:

diff
  model Issue {
    id          String        @id @default(cuid())
+   number      Int
    title       String
    description String?       // markdown content
    status      IssueStatus   @default(OPEN)
    priority    IssuePriority @default(MEDIUM)
    order       Int           @default(0)
    createdAt   DateTime      @default(now())
    updatedAt   DateTime      @updatedAt
 
    projectId   String
    project     Project       @relation(fields: [projectId], references: [id], onDelete: Cascade)
 
    epicId      String?
    epic        Epic?         @relation(fields: [epicId], references: [id], onDelete: SetNull)
 
    subtasks    Subtask[]
 
+   @@unique([projectId, number])
    @@index([projectId, status])
    @@index([epicId])
  }

The @@unique([projectId, number]) constraint guarantees no two issues in the same project share a number. This is not an auto-increment column — we will assign the number in the server action. Auto-increment would give globally unique numbers, but we want per-project numbering that starts at 1 for each project.

The Migration

The column is required (Int, not Int?), but existing rows need a value. Prisma will refuse to add a required column without a default when the table has data. Create the migration manually:

bash
mkdir -p prisma/migrations/20260323000000_add_issue_number

Write the SQL in prisma/migrations/20260323000000_add_issue_number/migration.sql:

sql
-- AlterTable: add number column as nullable first
ALTER TABLE "Issue" ADD COLUMN "number" INTEGER;
 
-- Backfill existing rows: assign sequential numbers per project
UPDATE "Issue" SET "number" = sub.row_num
FROM (
  SELECT id, ROW_NUMBER() OVER (PARTITION BY "projectId" ORDER BY "createdAt") AS row_num
  FROM "Issue"
) sub
WHERE "Issue".id = sub.id;
 
-- Make the column required now that all rows have values
ALTER TABLE "Issue" ALTER COLUMN "number" SET NOT NULL;
 
-- AddUniqueConstraint
CREATE UNIQUE INDEX "Issue_projectId_number_key" ON "Issue"("projectId", "number");

The strategy: add the column as nullable, backfill with ROW_NUMBER() partitioned by project, then make it required. This is a common pattern for adding required columns to existing tables.

Apply it and regenerate the client:

bash
npx prisma migrate deploy
npx prisma generate

Updating the Seed

Add a number to each seeded issue. In prisma/seed.ts, add sequential numbers to the issues array:

diff
  const issues = [
    {
+     number: 1,
      title: "Initialize Next.js project",
      ...
    },
    {
+     number: 2,
      title: "Set up Prisma schema",
      ...
    },
    {
+     number: 3,
      title: "Build four-column board layout",
      ...
    },
    // ... and so on through number: 7
  ];

Updating the Create Issue Action

The createIssue action needs to assign the next available number. In src/actions/create-issue.ts, query for the highest existing number in the project alongside the order query:

diff
- const maxOrder = await prisma.issue.aggregate({
-   where: { projectId, status },
-   _max: { order: true },
- });
-
- const order = (maxOrder._max.order ?? -1) + 1;
+ const [maxOrder, maxNumber] = await Promise.all([
+   prisma.issue.aggregate({
+     where: { projectId, status },
+     _max: { order: true },
+   }),
+   prisma.issue.aggregate({
+     where: { projectId },
+     _max: { number: true },
+   }),
+ ]);
+
+ const order = (maxOrder._max.order ?? -1) + 1;
+ const number = (maxNumber._max.number ?? 0) + 1;
 
  const issue = await prisma.issue.create({
    data: {
      title,
      status,
      order,
+     number,
      projectId,
      ...(epicId && { epicId }),
    },

We query the max number across the entire project (not filtered by status) so the numbering is globally sequential within the project.

Updating the Issue Card

Now for the visual changes. In src/components/board/IssueCard.tsx, update the title to show the ticket number, and apply a strikethrough when the issue is in the DONE column:

diff
  {/* Title */}
- <h3 className="text-sm font-medium leading-snug">{issue.title}</h3>
+ <h3 className={`text-sm font-medium leading-snug ${issue.status === "DONE" ? "line-through text-muted-foreground" : ""}`}>
+   <span className="text-muted-foreground font-normal">#{issue.number}</span>{" "}
+   {issue.title}
+ </h3>

Two things happen here:

  1. Ticket number. A #N prefix in muted text with normal font weight, so it reads as secondary information — #3 Build four-column board layout.
  2. Strikethrough. When issue.status === "DONE", the entire title gets line-through and text-muted-foreground. This gives instant visual feedback when you drag a card into the Done column. The strikethrough applies to both the number and the title text, reinforcing that the whole issue is complete.

The strikethrough is purely a display concern — it reads the status that is already part of the issue data. No new props or state needed.


Full File Reference

Here is every file we created or modified in this part:

In @dxsolo/ui (the component library):

FilePurpose
lib/components/ColorPicker/ColorPicker.tsxSwatch grid with radio group semantics
lib/components/ColorPicker/ColorPicker.stories.tsxStorybook stories for ColorPicker
lib/components/AlertDialog/AlertDialog.tsxConfirmation dialog with safe-focus pattern
lib/components/AlertDialog/AlertDialog.stories.tsxStorybook stories for AlertDialog
lib/index.tsUpdated exports

In the kanban app:

FilePurpose
src/actions/project-actions.tsgetProjects, createProject, deleteProject
src/actions/epic-actions.tscreateEpic, updateEpic, deleteEpic
prisma/schema.prismaAdded number field and unique constraint to Issue
prisma/migrations/.../migration.sqlBackfill migration for issue numbers
prisma/seed.tsAdded number to seeded issues
src/actions/project-actions.tsgetProjects, createProject, deleteProject
src/actions/epic-actions.tscreateEpic, updateEpic, deleteEpic
src/actions/create-issue.tsCreate issue with auto-numbered ticket and optional epic
src/app/projects/[projectId]/board/page.tsxAdded search params, epic filter, project list, passes epics
src/app/page.tsxUpdated to show create project form
src/components/layout/Sidebar.tsxRebuilt: project switcher, epic list, filters, CRUD modals
src/components/layout/CreateFirstProject.tsxWelcome screen for first-time users
src/components/board/CreateIssueForm.tsxInline issue creation form with epic selector
src/components/board/Column.tsxAdded "+" button and create form, threads epics
src/components/board/Board.tsxAdded projectId/epics props and issue creation handler
src/components/board/IssueCard.tsxTicket number prefix and strikethrough for done issues

What We Built in Part 5

Here is what we accomplished:

In the component library (@dxsolo/ui):

  • Built two new components: ColorPicker and AlertDialog
  • ColorPicker uses radio group semantics with curated color swatches and a controlled value/onChange API
  • AlertDialog implements the safe-focus pattern: focus lands on Cancel, not the destructive action. Uses role="alertdialog" for correct ARIA semantics
  • Both have Storybook stories

In the kanban app:

  • Built a project switcher with create functionality
  • Built full epic CRUD: create with color picker, edit, delete with confirmation
  • Added URL-based board filtering by epic
  • Added inline issue creation from column headers with optional epic assignment
  • Updated the home page with a first-project welcome screen
  • Added per-project ticket numbers (#1, #2, ...) with a schema migration and auto-assignment
  • Added strikethrough styling for completed issues in the Done column
  • Handled edge cases: filtering + drag-and-drop interaction, deleting the active filter's epic, creating issues while filtered

Key concepts we covered:

  • URL search params for filter state. Instead of client state, the filter lives in the URL. This makes it shareable, bookmarkable, and compatible with server-side data fetching. The server component's Prisma query filters at the database level.
  • revalidatePath for server-driven updates. After mutations (creating/editing/deleting epics), revalidatePath tells Next.js to re-run the server component. The sidebar and board update with fresh data without manual state management.
  • Safe-focus in destructive dialogs. Focus should land on the safe action (Cancel), not the destructive one (Delete). This prevents accidental confirmation when users press Enter reflexively.
  • Radio group semantics for custom controls. The color picker uses role="radiogroup" and role="radio" with aria-checked. Screen readers announce it as a selection control, not a grid of mysterious buttons.
  • Inline forms for quick entry. The issue creation form appears inside the column, not in a modal. This reduces context switching — the user sees exactly which column the issue will land in. Enter to submit, Escape to cancel.
  • Manual migrations for backfilling data. When adding a required column to a table with existing rows, Prisma cannot auto-generate the migration. The pattern: add nullable, backfill with SQL, alter to required. This comes up frequently in real projects.

Troubleshooting

If something is not working, check these common issues:

  • Project switcher does not navigate: Make sure useRouter is imported from next/navigation (not next/router). The component must be a client component ("use client").
  • Epic filter does not work: Check that the board page reads searchParams and passes epicFilter to the Prisma query's where clause. The searchParams prop must be awaited in Next.js 15+.
  • New @dxsolo/ui components not found: Make sure you updated lib/index.ts with the new exports, rebuilt, and installed the latest version. If using npm link, restart the dev server.
  • Created issues disappear on page refresh while filtered: This is expected — new issues have no epic, so they do not match the filter. Clear the filter to see them.
  • "+" button does not appear in column header: Check that the Column component received the projectId and onIssueCreated props from Board.

What is Next

In Part 6 — the final part — we polish the app for real use. That means a project settings page with a danger zone for deletion, keyboard shortcuts for power users, dark mode using the theme tokens we set up in @dxsolo/ui, and deploying the whole thing to Vercel. We will also publish the Storybook as a documentation site.

See you there.


Source code for this part: github.com/jpDxsoloOrg/solo-kanban/tree/part-5

Live demo: Coming in Part 6

@dxsolo/ui Storybook: jpdxsoloorg.github.io/jpComponent