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

Welcome back. In Part 3, we built a complete drag-and-drop system: cards move between columns, insert at precise positions, and persist to the database with optimistic updates. The board works. But clicking a card does nothing.
Now we fix that. By the end of this post, clicking an issue card will open a modal showing the full issue — an editable title, a markdown description with live preview, a subtask checklist, and metadata controls for priority and status. Changes auto-save as you type.
Along the way, we will add four new components to @dxsolo/ui: Textarea, Select, Checkbox, and Badge. This is the first time in the series we extend the component library. The goal is to show the workflow: identify a need in the app, build the component in the library, publish, and consume.
Series overview:
Before writing code, we need to decide how the detail view opens. There are three reasonable options:
Navigate to /projects/[projectId]/issues/[issueId]. This gives you a URL for every issue, which is great for sharing and deep-linking. But it means leaving the board view entirely. You lose context — you cannot see the other columns while editing an issue. For a solo dev's board, this feels disruptive.
A slide-out panel on the right side of the board, like Linear or Notion. The board stays visible but compressed. This is the most polished UX, but it requires rethinking the board layout to accommodate a variable-width panel. It also complicates drag and drop — what happens when you drag a card while the panel is open?
A centered modal overlay. The board is still visible (dimmed) behind the modal. Clicking outside or pressing Escape closes it. This is the simplest to implement, keeps the board context visible, and avoids layout complications with drag and drop.
We are going with a modal. Here is why:
Modal component from @dxsolo/ui handles the overlay, focus trapping, and close behavior. We do not need to build infrastructure.We can always migrate to a side panel later. The editing components we build will be the same either way.
Our Issue model has a description field that stores markdown. We need two things: a way to edit markdown and a way to render it. Let's evaluate the options.
Libraries like @uiw/react-md-editor, Milkdown, and Tiptap provide rich editing experiences — toolbars, keyboard shortcuts, live formatting. They are impressive but heavy. @uiw/react-md-editor pulls in CodeMirror. Tiptap uses ProseMirror. These are real editor frameworks with real bundle sizes.
For a solo dev's kanban board, we do not need a toolbar for bold and italic. We are developers. We know markdown syntax.
Use a plain <textarea> for editing and react-markdown for rendering. Toggle between write and preview modes using our Tabs component from @dxsolo/ui. This gives us:
react-markdown + remark-gfm are lightweight@tailwindcss/typography pluginWe will use a <textarea> for writing and react-markdown for rendering. A tabbed interface lets the user switch between "Write" and "Preview" modes — the same pattern GitHub uses for issue descriptions.
But we do not have a Textarea component in @dxsolo/ui. We also need Select for status/priority dropdowns, Checkbox for subtasks, and Badge for priority labels. Let's build these first.
The component library currently ships Button, Card, Input, Modal, and Tabs. The issue detail view needs four more primitives. We will build them in the library, publish a new version, and consume them in the kanban app.
If you have the library repo cloned locally, cd into it:
cd path/to/jpComponent/my-uiThe Textarea component follows the same pattern as Input — a forwardRef wrapper around a native element with optional label, helperText, and error props. The only difference is the element type and a few textarea-specific styles.
Create lib/components/Textarea/Textarea.tsx:
// lib/components/Textarea/Textarea.tsx
import { forwardRef, useId } from 'react';
import { cn } from '../../utils/cn';
type TextareaProps = React.ComponentPropsWithoutRef<'textarea'> & {
label?: string;
helperText?: string;
error?: string;
};
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, label, helperText, error, id: externalId, ...props }, ref) => {
const generatedId = useId();
const id = externalId ?? generatedId;
const helperId = `${id}-helper`;
const errorId = `${id}-error`;
const describedBy = error ? errorId : helperText ? helperId : undefined;
return (
<div className="flex flex-col gap-1.5">
{label && (
<label htmlFor={id} className="text-sm font-medium text-foreground">
{label}
</label>
)}
<textarea
ref={ref}
id={id}
className={cn(
'w-full min-h-[80px] rounded-md border px-3 py-2 text-sm',
'bg-background text-foreground placeholder:text-muted-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
'resize-y',
error
? 'border-destructive focus-visible:ring-destructive'
: 'border-input-border',
className
)}
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
{...props}
/>
{error && (
<p id={errorId} className="text-sm text-destructive" role="alert">
{error}
</p>
)}
{!error && helperText && (
<p id={helperId} className="text-sm text-muted-foreground">
{helperText}
</p>
)}
</div>
);
}
);
Textarea.displayName = 'Textarea';Create the barrel export lib/components/Textarea/index.ts:
export { Textarea } from './Textarea';This is nearly identical to Input. The differences: <textarea> instead of <input>, min-h-[80px] for a reasonable default height, py-2 instead of a fixed height, and resize-y to allow vertical resizing. The accessibility pattern is the same: auto-generated IDs, aria-describedby linking to helper/error text, and aria-invalid for error states.
Native <select> elements are underused in React. They are accessible by default, keyboard-navigable, and work on every device. A styled wrapper that matches our Input component is all we need.
Create lib/components/Select/Select.tsx:
// lib/components/Select/Select.tsx
import { forwardRef, useId } from 'react';
import { cn } from '../../utils/cn';
type SelectProps = React.ComponentPropsWithoutRef<'select'> & {
label?: string;
helperText?: string;
error?: string;
};
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ className, label, helperText, error, id: externalId, children, ...props }, ref) => {
const generatedId = useId();
const id = externalId ?? generatedId;
const helperId = `${id}-helper`;
const errorId = `${id}-error`;
const describedBy = error ? errorId : helperText ? helperId : undefined;
return (
<div className="flex flex-col gap-1.5">
{label && (
<label htmlFor={id} className="text-sm font-medium text-foreground">
{label}
</label>
)}
<select
ref={ref}
id={id}
className={cn(
'h-10 w-full rounded-md border px-3 text-sm appearance-none',
'bg-background text-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
error
? 'border-destructive focus-visible:ring-destructive'
: 'border-input-border',
className
)}
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
{...props}
>
{children}
</select>
{error && (
<p id={errorId} className="text-sm text-destructive" role="alert">
{error}
</p>
)}
{!error && helperText && (
<p id={helperId} className="text-sm text-muted-foreground">
{helperText}
</p>
)}
</div>
);
}
);
Select.displayName = 'Select';Create lib/components/Select/index.ts:
export { Select } from './Select';The Select follows the exact same wrapper pattern as Input and Textarea. The appearance-none class removes the browser's default dropdown arrow styling so you can add a custom one via CSS if desired. The children prop passes through <option> elements — the component does not abstract over the options themselves.
A checkbox with an optional label. This is simpler than the others because a checkbox does not need helper text or error states in most cases.
Create lib/components/Checkbox/Checkbox.tsx:
// lib/components/Checkbox/Checkbox.tsx
import { forwardRef, useId } from 'react';
import { cn } from '../../utils/cn';
type CheckboxProps = Omit<React.ComponentPropsWithoutRef<'input'>, 'type'> & {
label?: string;
};
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
({ className, label, id: externalId, ...props }, ref) => {
const generatedId = useId();
const id = externalId ?? generatedId;
return (
<div className="flex items-center gap-2">
<input
ref={ref}
type="checkbox"
id={id}
className={cn(
'h-4 w-4 rounded border border-input-border',
'text-primary bg-background',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
{label && (
<label htmlFor={id} className="text-sm text-foreground select-none cursor-pointer">
{label}
</label>
)}
</div>
);
}
);
Checkbox.displayName = 'Checkbox';Create lib/components/Checkbox/index.ts:
export { Checkbox } from './Checkbox';We omit type from the props with Omit<..., 'type'> to prevent consumers from accidentally changing the input type. The label is clickable via htmlFor — clicking the text toggles the checkbox, which is the expected behavior.
Priority labels ("Urgent", "High", "Med", "Low") and status pills ("Open", "In Progress") are colored inline labels. We have been hand-styling these with Tailwind classes. A Badge component with CVA variants makes them reusable and consistent.
Create lib/components/Badge/variants.ts:
// lib/components/Badge/variants.ts
import { cva } from 'class-variance-authority';
export const badgeVariants = cva(
[
'inline-flex items-center',
'text-xs font-medium',
'rounded-full px-2 py-0.5',
],
{
variants: {
intent: {
default: 'bg-muted text-foreground',
primary: 'bg-primary/10 text-primary',
success: 'bg-green-100 text-green-700',
warning: 'bg-amber-100 text-amber-700',
danger: 'bg-red-100 text-red-700',
info: 'bg-blue-100 text-blue-700',
},
size: {
sm: 'text-xs px-1.5 py-0.5',
md: 'text-xs px-2 py-0.5',
lg: 'text-sm px-2.5 py-1',
},
},
defaultVariants: {
intent: 'default',
size: 'md',
},
}
);Create lib/components/Badge/Badge.tsx:
// lib/components/Badge/Badge.tsx
import { forwardRef } from 'react';
import { type VariantProps } from 'class-variance-authority';
import { cn } from '../../utils/cn';
import { badgeVariants } from './variants';
type BadgeProps = React.ComponentPropsWithoutRef<'span'> &
VariantProps<typeof badgeVariants>;
export const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
({ className, intent, size, ...props }, ref) => (
<span
ref={ref}
className={cn(badgeVariants({ intent, size }), className)}
{...props}
/>
)
);
Badge.displayName = 'Badge';Create lib/components/Badge/index.ts:
export { Badge } from './Badge';
export { badgeVariants } from './variants';The Badge follows the same CVA pattern as Button: a variants.ts file defines the style matrix, the component applies them via cn(). The intent names (success, warning, danger, info) map naturally to our priority levels and status colors.
Every component in @dxsolo/ui has a Storybook story. The stories serve as documentation, a visual test harness, and a development sandbox. Let's add one for each new component.
Create lib/components/Textarea/Textarea.stories.tsx:
import type { Meta, StoryObj } from '@storybook/react';
import { Textarea } from './Textarea';
const meta: Meta<typeof Textarea> = {
component: Textarea,
argTypes: {
label: { control: 'text' },
helperText: { control: 'text' },
error: { control: 'text' },
placeholder: { control: 'text' },
disabled: { control: 'boolean' },
},
};
export default meta;
type Story = StoryObj<typeof Textarea>;
export const Default: Story = {
args: { placeholder: 'Write something...' },
};
export const WithLabel: Story = {
args: { label: 'Description', placeholder: 'Add a description...' },
};
export const WithHelperText: Story = {
args: {
label: 'Notes',
helperText: 'Supports markdown syntax',
placeholder: '## Heading\n\n- list item',
},
};
export const WithError: Story = {
args: {
label: 'Description',
error: 'Description is required',
},
};
export const Disabled: Story = {
args: { label: 'Locked', placeholder: 'Cannot edit', disabled: true },
};Create lib/components/Select/Select.stories.tsx:
import type { Meta, StoryObj } from '@storybook/react';
import { Select } from './Select';
const meta: Meta<typeof Select> = {
component: Select,
argTypes: {
label: { control: 'text' },
helperText: { control: 'text' },
error: { control: 'text' },
disabled: { control: 'boolean' },
},
};
export default meta;
type Story = StoryObj<typeof Select>;
export const Default: Story = {
render: (args) => (
<Select {...args}>
<option value="">Choose...</option>
<option value="open">Open</option>
<option value="in_progress">In Progress</option>
<option value="done">Done</option>
</Select>
),
};
export const WithLabel: Story = {
render: (args) => (
<Select {...args} label="Status">
<option value="open">Open</option>
<option value="in_progress">In Progress</option>
<option value="review">Review</option>
<option value="done">Done</option>
</Select>
),
};
export const WithError: Story = {
render: (args) => (
<Select {...args} label="Priority" error="Priority is required">
<option value="">Select...</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</Select>
),
};
export const Disabled: Story = {
render: (args) => (
<Select {...args} label="Locked" disabled>
<option value="fixed">Cannot change</option>
</Select>
),
};Create lib/components/Checkbox/Checkbox.stories.tsx:
import type { Meta, StoryObj } from '@storybook/react';
import { Checkbox } from './Checkbox';
const meta: Meta<typeof Checkbox> = {
component: Checkbox,
argTypes: {
label: { control: 'text' },
checked: { control: 'boolean' },
disabled: { control: 'boolean' },
},
};
export default meta;
type Story = StoryObj<typeof Checkbox>;
export const Default: Story = {
args: { label: 'Accept terms and conditions' },
};
export const Checked: Story = {
args: { label: 'Completed task', defaultChecked: true },
};
export const WithoutLabel: Story = {
args: {},
};
export const Disabled: Story = {
args: { label: 'Cannot toggle', disabled: true, defaultChecked: true },
};Create lib/components/Badge/Badge.stories.tsx:
import type { Meta, StoryObj } from '@storybook/react';
import { Badge } from './Badge';
const meta: Meta<typeof Badge> = {
component: Badge,
argTypes: {
intent: {
control: 'select',
options: ['default', 'primary', 'success', 'warning', 'danger', 'info'],
},
size: { control: 'select', options: ['sm', 'md', 'lg'] },
},
};
export default meta;
type Story = StoryObj<typeof Badge>;
export const Default: Story = {
args: { children: 'Badge' },
};
export const AllVariants: Story = {
render: () => (
<div className="flex flex-wrap gap-3">
{(['default', 'primary', 'success', 'warning', 'danger', 'info'] as const).map((intent) => (
<Badge key={intent} intent={intent}>
{intent}
</Badge>
))}
</div>
),
};
export const Sizes: Story = {
render: () => (
<div className="flex items-center gap-3">
{(['sm', 'md', 'lg'] as const).map((size) => (
<Badge key={size} intent="primary" size={size}>
{size}
</Badge>
))}
</div>
),
};You can verify all four render correctly by running Storybook locally:
npm run storybookUpdate lib/index.ts to export all four:
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 { Textarea } from './components/Textarea';
+ export { Select } from './components/Select';
+ export { Checkbox } from './components/Checkbox';
+ export { Badge } from './components/Badge';
export { cn } from './utils/cn';The @dxsolo/ui repo uses semantic-release for automated versioning and publishing. When you push to main, the CI/CD pipeline builds, tests, and publishes to npm automatically based on your commit messages.
Use Conventional Commits so semantic-release picks the right version bump:
git add .
git commit -m "feat: add Textarea, Select, Checkbox, and Badge components
- Textarea: multi-line input with label/error states, mirrors Input pattern
- Select: styled native select with label/error states
- Checkbox: input with optional clickable label
- Badge: inline label with CVA intent variants (danger, warning, info, etc.)
- Storybook stories for all four components"
git push origin mainThe feat: prefix triggers a minor version bump (e.g., 1.0.0 → 1.1.0). Once the pipeline completes, the new version is on npm.
In the kanban app, install the latest version:
cd path/to/solo-kanban
npm install @dxsolo/ui@latestIf you are developing both projects locally and do not want to wait for CI, you can use npm link to iterate faster:
# In the library — build first so the dist is current
cd path/to/jpComponent/my-ui
npm run build
npm link
# In the kanban app
cd path/to/solo-kanban
npm link @dxsolo/uiNow back in the kanban app. Install the markdown dependencies:
npm install react-markdown remark-gfm @tailwindcss/typographyThree packages:
react-markdown — renders markdown strings to React elements. No dangerouslySetInnerHTML. It parses the markdown into an AST and renders React components, which means it is safe by default.remark-gfm — a plugin that adds GitHub Flavored Markdown support: tables, strikethrough, task lists, and autolinks.@tailwindcss/typography — provides the prose class that styles rendered HTML (headings, paragraphs, lists, code blocks) with sensible typographic defaults.Add the typography plugin to your CSS. With Tailwind v4, plugins are imported directly in CSS:
/* src/app/globals.css */
@import "tailwindcss";
@import "@dxsolo/ui/theme.css";
@plugin "@tailwindcss/typography";Add the @plugin directive after your existing imports.
We need three things to happen when a user clicks a card:
Board component tracks which issue is selected (or null)IssueCard component calls a callback when clicked (not when dragged)This is trickier than it sounds. A drag starts with a mousedown, which is also the start of a click. If we attach an onClick handler to the card, it will fire when the user releases a drag, which is not what we want.
Pragmatic Drag and Drop helps here. When a drag actually starts (the user moves the mouse enough to trigger a drag), the browser cancels the click event. So a plain onClick on the card wrapper will only fire for actual clicks, not drags. We do not need any special handling.
Update IssueCard to accept and call an onSelect prop:
interface IssueCardProps {
issue: IssueWithRelations;
onMoveIssue: (issueId: string, newStatus: IssueStatus, newOrder: number) => void;
+ onSelect: (issue: IssueWithRelations) => void;
}- export function IssueCard({ issue, onMoveIssue }: IssueCardProps) {
+ export function IssueCard({ issue, onMoveIssue, onSelect }: IssueCardProps) {Add the click handler to the card wrapper div:
- <div ref={ref} className="relative">
+ <div ref={ref} className="relative" onClick={() => onSelect(issue)}>Three lines changed. The onClick fires only on genuine clicks — not on drags — because the browser suppresses click events when a native drag occurs.
Update Board.tsx to track the selected issue and pass onSelect through:
export function Board({ issues: initialIssues }: BoardProps) {
const [issues, setIssues] = useState(initialIssues);
const [isMoving, setIsMoving] = useState(false);
+ const [selectedIssue, setSelectedIssue] = useState<IssueWithRelations | null>(null);In Column.tsx, add the new prop to the interface:
interface ColumnProps {
status: IssueStatus;
label: string;
issues: IssueWithRelations[];
onMoveIssue: (issueId: string, newStatus: IssueStatus, newOrder: number) => void;
+ onSelectIssue: (issue: IssueWithRelations) => void;
}Destructure it in the function signature:
- export function Column({ status, label, issues, onMoveIssue }: ColumnProps) {
+ export function Column({ status, label, issues, onMoveIssue, onSelectIssue }: ColumnProps) {And pass it through to each card:
- <IssueCard key={issue.id} issue={issue} onMoveIssue={onMoveIssue} />
+ <IssueCard key={issue.id} issue={issue} onMoveIssue={onMoveIssue} onSelect={onSelectIssue} />Back in Board.tsx, pass the callback when rendering each column:
<Column
key={col.status}
status={col.status}
label={col.label}
issues={col.issues}
onMoveIssue={handleMoveIssue}
+ onSelectIssue={setSelectedIssue}
/>The data flow is: IssueCard → Column → Board → selectedIssue state. Nothing is fetched — the full issue data is already in memory from the board's issue list.
Now we build the modal content. This is where our new @dxsolo/ui components pay off. Instead of hand-styling native elements, we import Select, Textarea, Badge, and Checkbox from the library.
Create src/components/board/IssueDetailModal.tsx:
"use client";
import { useState, useCallback, useEffect, useRef } from "react";
import {
Modal,
Button,
Select,
Badge,
Tabs,
TabsList,
TabsTrigger,
TabsContent,
Textarea,
} from "@dxsolo/ui";
import type { IssueWithRelations } from "@/types";
import { IssueStatus, IssuePriority } from "@prisma/client";
import { updateIssue } from "@/actions/update-issue";
import { MarkdownPreview } from "./MarkdownPreview";
import { SubtaskList } from "./SubtaskList";
interface IssueDetailModalProps {
issue: IssueWithRelations;
onClose: () => void;
onUpdate: (updated: IssueWithRelations) => void;
}
const statusOptions: { value: IssueStatus; label: string }[] = [
{ value: "OPEN", label: "Open" },
{ value: "IN_PROGRESS", label: "In Progress" },
{ value: "REVIEW", label: "Review" },
{ value: "DONE", label: "Done" },
];
const priorityOptions: { value: IssuePriority; label: string }[] = [
{ value: "URGENT", label: "Urgent" },
{ value: "HIGH", label: "High" },
{ value: "MEDIUM", label: "Medium" },
{ value: "LOW", label: "Low" },
];
const priorityBadgeIntent: Record<IssuePriority, "danger" | "warning" | "info" | "default"> = {
URGENT: "danger",
HIGH: "warning",
MEDIUM: "info",
LOW: "default",
};
export function IssueDetailModal({ issue, onClose, onUpdate }: IssueDetailModalProps) {
const [title, setTitle] = useState(issue.title);
const [description, setDescription] = useState(issue.description ?? "");
const [status, setStatus] = useState(issue.status);
const [priority, setPriority] = useState(issue.priority);
const [isSaving, setIsSaving] = useState(false);
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Track latest values for flush-on-unmount
const latestFieldsRef = useRef({ title, description, status, priority });
useEffect(() => {
latestFieldsRef.current = { title, description, status, priority };
}, [title, description, status, priority]);
// Auto-save with debounce
const debouncedSave = useCallback(
(fields: Partial<{ title: string; description: string; status: IssueStatus; priority: IssuePriority }>) => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = setTimeout(async () => {
setIsSaving(true);
try {
const updated = await updateIssue(issue.id, fields);
onUpdate({ ...issue, ...updated });
} catch (error) {
console.error("Failed to save:", error);
} finally {
setIsSaving(false);
}
}, 500);
},
[issue, onUpdate]
);
// Flush pending save on unmount
useEffect(() => {
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
const fields = latestFieldsRef.current;
updateIssue(issue.id, fields).catch(console.error);
}
};
}, [issue.id]);
const handleTitleChange = (newTitle: string) => {
setTitle(newTitle);
debouncedSave({ title: newTitle });
};
const handleDescriptionChange = (newDescription: string) => {
setDescription(newDescription);
debouncedSave({ description: newDescription });
};
const handleStatusChange = (newStatus: IssueStatus) => {
setStatus(newStatus);
debouncedSave({ status: newStatus });
};
const handlePriorityChange = (newPriority: IssuePriority) => {
setPriority(newPriority);
debouncedSave({ priority: newPriority });
};
return (
<Modal open onClose={onClose} title="" className="max-w-2xl">
<div className="flex flex-col gap-6">
{/* Title */}
<input
type="text"
value={title}
onChange={(e) => handleTitleChange(e.target.value)}
className="text-xl font-semibold bg-transparent border-none outline-none w-full placeholder-gray-400 focus:ring-0"
placeholder="Issue title"
/>
{/* Metadata row */}
<div className="flex items-center gap-4 flex-wrap">
<Select
value={status}
onChange={(e) => handleStatusChange(e.target.value as IssueStatus)}
label="Status"
className="w-auto"
>
{statusOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</Select>
<Select
value={priority}
onChange={(e) => handlePriorityChange(e.target.value as IssuePriority)}
label="Priority"
className="w-auto"
>
{priorityOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</Select>
{issue.epic && (
<Badge
intent="primary"
style={{
backgroundColor: `${issue.epic.color}20`,
color: issue.epic.color,
}}
>
{issue.epic.name}
</Badge>
)}
{isSaving && (
<span className="text-xs text-muted-foreground">Saving...</span>
)}
</div>
{/* Description: tabbed write/preview */}
<Tabs defaultValue="write">
<TabsList>
<TabsTrigger value="write">Write</TabsTrigger>
<TabsTrigger value="preview">Preview</TabsTrigger>
</TabsList>
<TabsContent value="write">
<Textarea
value={description}
onChange={(e) => handleDescriptionChange(e.target.value)}
placeholder="Add a description... (supports markdown)"
className="min-h-[200px] font-mono"
/>
</TabsContent>
<TabsContent value="preview">
<div className="min-h-[200px] p-3 border border-border rounded-md bg-background">
{description ? (
<MarkdownPreview content={description} />
) : (
<p className="text-sm text-muted-foreground">Nothing to preview</p>
)}
</div>
</TabsContent>
</Tabs>
{/* Subtasks */}
<SubtaskList issueId={issue.id} subtasks={issue.subtasks} />
{/* Footer */}
<div className="flex items-center justify-between text-xs text-muted-foreground pt-2 border-t border-border">
<span>Created {new Date(issue.createdAt).toLocaleDateString()}</span>
<span>Updated {new Date(issue.updatedAt).toLocaleDateString()}</span>
</div>
</div>
</Modal>
);
}Let's break this down.
Title editing. The title is a plain <input> styled to look like a heading, not a form field. No visible border, no background — it looks like static text until you click on it. We use a raw <input> here instead of @dxsolo/ui's Input component because the Input component includes a wrapper div, label, and form-field styling that would fight against the inline heading appearance we want.
Metadata controls. Status and priority use our new Select component. The label prop gives each dropdown a label without extra markup. Changes save immediately (after the 500ms debounce).
Priority badge. The Badge component from @dxsolo/ui with the priorityBadgeIntent mapping replaces the hand-styled spans we used on the board cards. For the epic label, we override Badge's colors with inline styles since epic colors are dynamic.
Tabbed markdown editor. The description uses our Tabs component to switch between Write and Preview modes. The Write tab uses our new Textarea component with font-mono for a code-friendly editing experience. The Preview tab renders the markdown through react-markdown.
Auto-save with debounce. Every field change triggers debouncedSave, which waits 500ms of inactivity before calling the server action. This means:
Flush on unmount. When the modal closes, the cleanup effect checks for a pending debounced save. If one exists, it cancels the timeout and fires the save immediately using latestFieldsRef — a ref that always holds the current field values. Without the ref, the cleanup closure would capture stale values from when the effect was created.
Create src/components/board/MarkdownPreview.tsx:
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
interface MarkdownPreviewProps {
content: string;
}
export function MarkdownPreview({ content }: MarkdownPreviewProps) {
return (
<div className="prose prose-sm max-w-none prose-gray">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
);
}This is intentionally tiny. The prose class from @tailwindcss/typography does all the heavy lifting — it styles headings, paragraphs, lists, code blocks, blockquotes, tables, and links with sensible defaults.
Key Tailwind classes:
prose — applies typographic styles to child HTML elementsprose-sm — uses smaller font sizes, appropriate for a modal contextmax-w-none — removes the default max-width that prose applies (we want it to fill the modal width)prose-gray — uses gray tones for text, matching our board's color schemeThe remarkGfm plugin enables GitHub Flavored Markdown. Without it, tables, strikethrough (~~text~~), task lists (- [x] done), and autolinked URLs would not render.
Note that this component does not have "use client" at the top. ReactMarkdown renders to React elements — it does not use browser APIs or hooks. It can render on the server or client. Since it is imported by a client component (IssueDetailModal), it will run on the client, but it does not need the directive itself.
Subtasks need their own component because they have their own server interactions: toggling completion, adding new subtasks, and deleting. This is where our new Checkbox component from @dxsolo/ui comes in.
Create src/components/board/SubtaskList.tsx:
"use client";
import { useState, useRef } from "react";
import type { Subtask } from "@prisma/client";
import { toggleSubtask, addSubtask, deleteSubtask } from "@/actions/subtask-actions";
import { Button, Checkbox, Input } from "@dxsolo/ui";
interface SubtaskListProps {
issueId: string;
subtasks: Subtask[];
}
export function SubtaskList({ issueId, subtasks: initialSubtasks }: SubtaskListProps) {
const [subtasks, setSubtasks] = useState(initialSubtasks.sort((a, b) => a.order - b.order));
const [newTitle, setNewTitle] = useState("");
const [isAdding, setIsAdding] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const completed = subtasks.filter((s) => s.completed).length;
const handleToggle = async (subtaskId: string, checked: boolean) => {
// Optimistic update
setSubtasks((prev) =>
prev.map((s) => (s.id === subtaskId ? { ...s, completed: checked } : s))
);
try {
await toggleSubtask(subtaskId, checked);
} catch (error) {
console.error("Failed to toggle subtask:", error);
// Rollback
setSubtasks((prev) =>
prev.map((s) => (s.id === subtaskId ? { ...s, completed: !checked } : s))
);
}
};
const handleAdd = async () => {
const trimmed = newTitle.trim();
if (!trimmed) return;
setIsAdding(true);
try {
const created = await addSubtask(issueId, trimmed, subtasks.length);
setSubtasks((prev) => [...prev, created]);
setNewTitle("");
inputRef.current?.focus();
} catch (error) {
console.error("Failed to add subtask:", error);
} finally {
setIsAdding(false);
}
};
const handleDelete = async (subtaskId: string) => {
const previous = subtasks;
setSubtasks((prev) => prev.filter((s) => s.id !== subtaskId));
try {
await deleteSubtask(subtaskId);
} catch (error) {
console.error("Failed to delete subtask:", error);
setSubtasks(previous);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleAdd();
}
};
return (
<div>
{/* Header with progress */}
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-medium text-foreground">Subtasks</h4>
{subtasks.length > 0 && (
<span className="text-xs text-muted-foreground">
{completed}/{subtasks.length} done
</span>
)}
</div>
{/* Progress bar */}
{subtasks.length > 0 && (
<div className="h-1.5 bg-muted rounded-full overflow-hidden mb-3">
<div
className="h-full bg-green-500 rounded-full transition-all duration-300"
style={{ width: `${(completed / subtasks.length) * 100}%` }}
/>
</div>
)}
{/* Subtask items */}
<div className="flex flex-col gap-1">
{subtasks.map((subtask) => (
<div
key={subtask.id}
className="flex items-center gap-2 group py-1 px-2 rounded hover:bg-muted"
>
<Checkbox
checked={subtask.completed}
onChange={(e) => handleToggle(subtask.id, e.target.checked)}
label={subtask.title}
className={subtask.completed ? "opacity-50" : ""}
/>
<span className="flex-1" />
<button
onClick={() => handleDelete(subtask.id)}
className="text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity text-xs"
aria-label={`Delete subtask: ${subtask.title}`}
>
✕
</button>
</div>
))}
</div>
{/* Add subtask input */}
<div className="flex items-center gap-2 mt-2">
<Input
ref={inputRef}
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Add a subtask..."
disabled={isAdding}
/>
<Button
intent="secondary"
size="sm"
onClick={handleAdd}
disabled={isAdding || !newTitle.trim()}
>
Add
</Button>
</div>
</div>
);
}Notice we are importing Checkbox, Input, and Button — all from @dxsolo/ui. No hand-styled form elements.
Checkbox toggling. Each subtask uses the Checkbox component with its label prop. Toggling optimistically updates the local state and calls a server action. If the server call fails, it rolls back. The opacity-50 class on completed checkboxes provides a visual de-emphasis.
Adding subtasks. The Input component from @dxsolo/ui gives us consistent form styling. Pressing Enter or clicking Add creates a new subtask. We wait for the server here — unlike toggles, we need the server-generated ID for the new subtask.
Deleting subtasks. Each subtask row shows a delete button on hover (the ✕ that appears via group-hover:opacity-100). Deletes are optimistic with rollback.
Progress display. A progress bar and counter ("3/5 done") show how many subtasks are complete. The bar animates smoothly with transition-all duration-300.
We need two server actions: one for updating issue fields and one for subtask operations.
Create src/actions/update-issue.ts:
"use server";
import { prisma } from "@/lib/prisma";
import { IssueStatus, IssuePriority } from "@prisma/client";
interface UpdateIssueInput {
title?: string;
description?: string;
status?: IssueStatus;
priority?: IssuePriority;
}
export async function updateIssue(issueId: string, data: UpdateIssueInput) {
const updated = await prisma.issue.update({
where: { id: issueId },
data,
include: {
epic: true,
subtasks: {
orderBy: { order: "asc" },
},
},
});
return updated;
}This is intentionally simple. Prisma's update only modifies the fields you pass — if data is { title: "new title" }, only the title changes. The include returns the full issue with relations so the client can update its state with fresh data.
Note that we do not handle status changes with order reindexing here. When the user changes status via the dropdown in the detail modal, the issue keeps its current order value. This means it could temporarily share an order with another issue in the new column. The next time the board loads from the server (on page refresh), the sort((a, b) => a.order - b.order) in the board ensures a deterministic render order even with duplicate order values. For a solo dev tool, this is fine. If you wanted to be precise, you could call the same moveIssue action from Part 3 instead.
Create src/actions/subtask-actions.ts:
"use server";
import { prisma } from "@/lib/prisma";
export async function toggleSubtask(subtaskId: string, completed: boolean) {
return prisma.subtask.update({
where: { id: subtaskId },
data: { completed },
});
}
export async function addSubtask(issueId: string, title: string, order: number) {
return prisma.subtask.create({
data: {
title,
order,
issueId,
},
});
}
export async function deleteSubtask(subtaskId: string) {
return prisma.subtask.delete({
where: { id: subtaskId },
});
}Three small functions, each a single Prisma call. Server actions are a natural fit for CRUD operations like this — they are just async functions that run on the server, and Next.js handles the serialization.
toggleSubtask updates the completed boolean. Nothing else changes.
addSubtask creates a new subtask at the given order position. We pass subtasks.length as the order from the client, which appends it to the end.
deleteSubtask removes the subtask entirely. We do not reindex the remaining subtasks' order values — gaps in order values do not matter because we sort by order when displaying, and the relative order is preserved.
Now we connect the modal to the board. Back in Board.tsx, render the modal when an issue is selected:
+ import { IssueDetailModal } from "./IssueDetailModal";
// ... inside the Board component return:
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}
/>
))}
</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>
);The onUpdate callback does two things:
selectedIssue is refreshed so the modal shows the latest data, including the updated updatedAt timestamp in the footer.The issue prop uses issues.find() to always read the latest version from board state — if the issue was dragged to a new column before opening the modal, this ensures the modal has the post-drag data.
When the modal closes, we want to fetch fresh data to pick up subtask changes that happened during the session:
// In Board.tsx
import { getIssue } from "@/actions/get-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]);And the server action to fetch a single issue:
// src/actions/get-issue.ts
"use server";
import { prisma } from "@/lib/prisma";
export async function getIssue(issueId: string) {
return prisma.issue.findUnique({
where: { id: issueId },
include: {
epic: true,
subtasks: {
orderBy: { order: "asc" },
},
},
});
}Now when the modal closes, we fetch the latest issue data from the server — including any subtask changes — and update the board. The card's subtask progress bar stays current.
Visit http://localhost:3000. Click on any issue card. You should see:
Select from @dxsolo/uiBadge (if the issue has one)Textarea from @dxsolo/uiCheckbox components and an "Add" inputTry these interactions:
## Heading, - list item, `code`. Switch to Preview — it renders with proper typography.There is a subtlety with the onUpdate callback. When the user changes status via the detail modal, the optimistic update modifies the issue's status field in the board's state. But the issue's order value does not change. It keeps whatever order it had in the old column.
This means after a status change via the modal:
sort((a, b) => a.order - b.order) produces a deterministic order even with duplicatesFor a solo dev board, this is acceptable. The card ends up in the right column, and the exact position within that column is resolved on reload. If you want immediate correct ordering, replace the status dropdown's save with the moveIssue action from Part 3, passing targetColumnIssues.length as the new order.
What if the user types something and immediately closes the modal? The debounce timeout has not fired yet. We handled this in the IssueDetailModal component with the flush-on-unmount pattern:
latestFieldsRef always holds the current field valuesWithout the ref, the cleanup closure would capture stale values from when the effect was created. This is a common React pitfall with cleanup functions and debounced operations.
When subtasks are added, toggled, or deleted in the modal, the board's issue card shows the old subtask count and progress bar. The onUpdate callback updates the issue's top-level fields (title, status, priority) but not subtasks.
The handleCloseDetail function solves this by fetching the fresh issue (including subtasks) when the modal closes. The card's progress bar updates to reflect the current state.
Here is every file we created or modified in this part:
In @dxsolo/ui (the component library):
| File | Purpose |
|---|---|
lib/components/Textarea/Textarea.tsx | Multi-line text input with label and error states |
lib/components/Textarea/Textarea.stories.tsx | Storybook stories for Textarea |
lib/components/Select/Select.tsx | Styled native select with label and error states |
lib/components/Select/Select.stories.tsx | Storybook stories for Select |
lib/components/Checkbox/Checkbox.tsx | Checkbox input with optional label |
lib/components/Checkbox/Checkbox.stories.tsx | Storybook stories for Checkbox |
lib/components/Badge/Badge.tsx | Colored inline label with CVA intent variants |
lib/components/Badge/variants.ts | Badge style variants (danger, warning, info, etc.) |
lib/components/Badge/Badge.stories.tsx | Storybook stories for Badge |
lib/index.ts | Updated exports |
In the kanban app:
| File | Purpose |
|---|---|
src/components/board/IssueDetailModal.tsx | Modal with title editing, metadata controls, markdown, subtasks |
src/components/board/MarkdownPreview.tsx | Renders markdown with react-markdown and prose styling |
src/components/board/SubtaskList.tsx | Subtask checkboxes, add/delete, optimistic updates |
src/actions/update-issue.ts | Server action for updating issue fields |
src/actions/subtask-actions.ts | Server actions for subtask CRUD |
src/actions/get-issue.ts | Server action to fetch a single issue with relations |
src/components/board/IssueCard.tsx | Added onSelect prop and click handler |
src/components/board/Column.tsx | Added onSelectIssue prop passthrough |
src/components/board/Board.tsx | Added selection state, modal rendering, close handler |
src/app/globals.css | Added @tailwindcss/typography plugin |
Here is what we accomplished:
In the component library (@dxsolo/ui):
Textarea, Select, Checkbox, and BadgeforwardRef, cn() for class merging, semantic theme colors, accessibility attributesBadge uses CVA for intent variants, matching the Button patternIn the kanban app:
Textarea for writing, react-markdown for rendering, tabbed with Tabs@tailwindcss/typography for beautiful prose renderingCheckbox toggling, adding, and deleting — all with optimistic updatesKey concepts we covered:
onClick on a draggable element works correctly without any special detection.class-variance-authority creates a style matrix from variant names and values. It composes cleanly with Tailwind and keeps component APIs type-safe.If something is not working, check these common issues:
onSelect is passed through Column to IssueCard, and that the onClick is on the wrapper div.@plugin "@tailwindcss/typography" is in globals.css and the prose class is on the markdown wrapper.@dxsolo/ui components not found: make sure you updated the library's lib/index.ts with the new exports, rebuilt (npm run build), and installed the latest version in the kanban app. If using npm link, restart the dev server.src/actions/update-issue.ts has "use server" at the top.handleCloseDetail fetches the issue and updates state before clearing selectedIssue.@source directive in globals.css includes the @dxsolo/ui package so Tailwind scans the new component classes.In Part 5, we build project and epic management. Right now we have one hardcoded project from the seed data. We will add the ability to create and switch between projects, manage epics with color pickers, and filter the board by epic. The sidebar's placeholder navigation links will become functional.
See you there.
Source code for this part: github.com/jpDxsoloOrg/solo-kanban/tree/part-4
Live demo: Coming in Part 6
@dxsolo/ui Storybook: jpdxsoloorg.github.io/jpComponent