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

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:
@dxsolo/ui: ColorPicker and AlertDialogSeries overview:
Right now the sidebar is mostly decorative. Here is what it needs to become:
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.
Before touching the kanban app, we need two new primitives in the component library.
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.
cd path/to/jpComponent/my-uiCreate lib/components/ColorPicker/ColorPicker.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:
export { ColorPicker } from './ColorPicker';The design decisions:
colors prop lets consumers override this list.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.value and onChange follow the standard React controlled pattern. No internal state.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:
// 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:
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.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.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.
Create lib/components/ColorPicker/ColorPicker.stories.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:
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:
npm run storybookUpdate lib/index.ts:
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';Use conventional commits so semantic-release picks up the change:
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 mainOnce CI/CD completes, install the new version in the kanban app:
cd path/to/solo-kanban
npm install @dxsolo/ui@latestPart 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.
Create src/actions/project-actions.ts:
"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.
Create src/actions/epic-actions.ts:
"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 src/actions/create-issue.ts:
"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.
Before building the UI, let's decide how filtering works. We will use URL search params:
/projects/abc123/board → no filter, show all issues
/projects/abc123/board?epic=def456 → filter by epicThis approach has clear advantages:
searchParams and can filter the database query directly — fewer issues sent to the client.The board page needs to read the epic search param and filter issues accordingly:
// 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:
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:
+ 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:
// 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>
);
}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:
"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.
The Select component replaces the static project name heading. It shows all projects as options and navigates on change:
<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.
Each epic renders as a row with a color dot, name, and hover-visible edit/delete buttons:
<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:
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.
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:
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.
Until now, issues only existed via the seed script. Let's add a "+" button to each column header that opens a quick-create form.
Create src/components/board/CreateIssueForm.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.
Update Column.tsx to accept the new props and render the create form:
+ 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;
}- 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:
<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:
<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.
The Board needs to pass projectId and handle the onIssueCreated callback:
+ 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:
const handleIssueCreated = useCallback((issue: IssueWithRelations) => {
setIssues((prev) => [...prev, issue]);
}, []);Pass the new props to each Column:
<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:
"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:
<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.
The home page currently shows a "No projects yet" message with no way to create a project. Let's add a create button:
// 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:
"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.
Start the dev server:
npm run devVisit 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:
?epic=.... The selected epic is highlighted. Click it again to clear the filter, or click "Clear filter".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:
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:
<Modal
open={showCreateProject}
onClose={() => setShowCreateProject(false)}
...
>Here is the chain:
setNewProjectDescription causes the Sidebar to re-render.onClose arrow function (same logic, new reference).handleKeyDown depends on onClose, so useCallback returns a new function.useEffect depends on handleKeyDown, so it re-runs.requestAnimationFrame → focuses the first focusable element → the Name input.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:
// 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.
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.
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.
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.
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.
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.
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:
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 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:
mkdir -p prisma/migrations/20260323000000_add_issue_numberWrite the SQL in prisma/migrations/20260323000000_add_issue_number/migration.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:
npx prisma migrate deploy
npx prisma generateAdd a number to each seeded issue. In prisma/seed.ts, add sequential numbers to the issues array:
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
];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:
- 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.
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:
{/* 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:
#N prefix in muted text with normal font weight, so it reads as secondary information — #3 Build four-column board layout.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.
Here is every file we created or modified in this part:
In @dxsolo/ui (the component library):
| File | Purpose |
|---|---|
lib/components/ColorPicker/ColorPicker.tsx | Swatch grid with radio group semantics |
lib/components/ColorPicker/ColorPicker.stories.tsx | Storybook stories for ColorPicker |
lib/components/AlertDialog/AlertDialog.tsx | Confirmation dialog with safe-focus pattern |
lib/components/AlertDialog/AlertDialog.stories.tsx | Storybook stories for AlertDialog |
lib/index.ts | Updated exports |
In the kanban app:
| File | Purpose |
|---|---|
src/actions/project-actions.ts | getProjects, createProject, deleteProject |
src/actions/epic-actions.ts | createEpic, updateEpic, deleteEpic |
prisma/schema.prisma | Added number field and unique constraint to Issue |
prisma/migrations/.../migration.sql | Backfill migration for issue numbers |
prisma/seed.ts | Added number to seeded issues |
src/actions/project-actions.ts | getProjects, createProject, deleteProject |
src/actions/epic-actions.ts | createEpic, updateEpic, deleteEpic |
src/actions/create-issue.ts | Create issue with auto-numbered ticket and optional epic |
src/app/projects/[projectId]/board/page.tsx | Added search params, epic filter, project list, passes epics |
src/app/page.tsx | Updated to show create project form |
src/components/layout/Sidebar.tsx | Rebuilt: project switcher, epic list, filters, CRUD modals |
src/components/layout/CreateFirstProject.tsx | Welcome screen for first-time users |
src/components/board/CreateIssueForm.tsx | Inline issue creation form with epic selector |
src/components/board/Column.tsx | Added "+" button and create form, threads epics |
src/components/board/Board.tsx | Added projectId/epics props and issue creation handler |
src/components/board/IssueCard.tsx | Ticket number prefix and strikethrough for done issues |
Here is what we accomplished:
In the component library (@dxsolo/ui):
ColorPicker and AlertDialogColorPicker uses radio group semantics with curated color swatches and a controlled value/onChange APIAlertDialog implements the safe-focus pattern: focus lands on Cancel, not the destructive action. Uses role="alertdialog" for correct ARIA semanticsIn the kanban app:
#1, #2, ...) with a schema migration and auto-assignmentKey concepts we covered:
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.role="radiogroup" and role="radio" with aria-checked. Screen readers announce it as a selection control, not a grid of mysterious buttons.If something is not working, check these common issues:
useRouter is imported from next/navigation (not next/router). The component must be a client component ("use client").searchParams and passes epicFilter to the Prisma query's where clause. The searchParams prop must be awaited in Next.js 15+.@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.Column component received the projectId and onIssueCreated props from Board.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