Kanban shenanigans

This commit is contained in:
2025-07-05 10:08:09 +02:00
parent a12a8e4e1b
commit 75623f37bc
13 changed files with 674 additions and 11 deletions

109
package-lock.json generated
View File

@ -8,12 +8,16 @@
"name": "admin.gaertan.art",
"version": "0.1.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.1.1",
"@prisma/client": "^6.11.1",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-navigation-menu": "^1.2.13",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
@ -42,7 +46,7 @@
"prisma": "^6.11.1",
"tailwindcss": "^4",
"tw-animate-css": "^1.3.5",
"typescript": "^5"
"typescript": "^5.8.3"
}
},
"node_modules/@alloc/quick-lru": {
@ -101,6 +105,45 @@
}
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz",
@ -1079,11 +1122,33 @@
"url": "https://opencollective.com/pkgr"
}
},
"node_modules/@prisma/client": {
"version": "6.11.1",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.11.1.tgz",
"integrity": "sha512-5CLFh8QP6KxRm83pJ84jaVCeSVPQr8k0L2SEtOJHwdkS57/VQDcI/wQpGmdyOZi+D9gdNabdo8tj1Uk+w+upsQ==",
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18"
},
"peerDependencies": {
"prisma": "*",
"typescript": ">=5.1.0"
},
"peerDependenciesMeta": {
"prisma": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/@prisma/config": {
"version": "6.11.1",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.11.1.tgz",
"integrity": "sha512-z6rCTQN741wxDq82cpdzx2uVykpnQIXalLhrWQSR0jlBVOxCIkz3HZnd8ern3uYTcWKfB3IpVAF7K2FU8t/8AQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"jiti": "2.4.2"
@ -1093,14 +1158,14 @@
"version": "6.11.1",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.11.1.tgz",
"integrity": "sha512-lWRb/YSWu8l4Yum1UXfGLtqFzZkVS2ygkWYpgkbgMHn9XJlMITIgeMvJyX5GepChzhmxuSuiq/MY/kGFweOpGw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "6.11.1",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.11.1.tgz",
"integrity": "sha512-6eKEcV6V8W2eZAUwX2xTktxqPM4vnx3sxz3SDtpZwjHKpC6lhOtc4vtAtFUuf5+eEqBk+dbJ9Dcaj6uQU+FNNg==",
"dev": true,
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@ -1114,14 +1179,14 @@
"version": "6.11.1-1.f40f79ec31188888a2e33acda0ecc8fd10a853a9",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.11.1-1.f40f79ec31188888a2e33acda0ecc8fd10a853a9.tgz",
"integrity": "sha512-swFJTOOg4tHyOM1zB/pHb3MeH0i6t7jFKn5l+ZsB23d9AQACuIRo9MouvuKGvnDogzkcjbWnXi/NvOZ0+n5Jfw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "6.11.1",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.11.1.tgz",
"integrity": "sha512-NBYzmkXTkj9+LxNPRSndaAeALOL1Gr3tjvgRYNqruIPlZ6/ixLeuE/5boYOewant58tnaYFZ5Ne0jFBPfGXHpQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.11.1",
@ -1133,7 +1198,7 @@
"version": "6.11.1",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.11.1.tgz",
"integrity": "sha512-b2Z8oV2gwvdCkFemBTFd0x4lsL4O2jLSx8lB7D+XqoFALOQZPa7eAPE1NU0Mj1V8gPHRxIsHnyUNtw2i92psUw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.11.1"
@ -1627,6 +1692,30 @@
}
}
},
"node_modules/@radix-ui/react-progress": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz",
"integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz",
@ -5168,7 +5257,7 @@
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
"integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
"dev": true,
"devOptional": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
@ -6215,7 +6304,7 @@
"version": "6.11.1",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.11.1.tgz",
"integrity": "sha512-VzJToRlV0s9Vu2bfqHiRJw73hZNCG/AyJeX+kopbu4GATTjTUdEWUteO3p4BLYoHpMS4o8pD3v6tF44BHNZI1w==",
"dev": true,
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@ -7266,7 +7355,7 @@
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",

View File

@ -9,12 +9,16 @@
"lint": "next lint"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.1.1",
"@prisma/client": "^6.11.1",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-navigation-menu": "^1.2.13",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
@ -43,6 +47,6 @@
"prisma": "^6.11.1",
"tailwindcss": "^4",
"tw-animate-css": "^1.3.5",
"typescript": "^5"
"typescript": "^5.8.3"
}
}

View File

@ -0,0 +1,97 @@
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('ADMIN', 'ARTIST');
-- CreateEnum
CREATE TYPE "RequestStatus" AS ENUM ('PENDING', 'ACCEPTED', 'IN_PROGRESS', 'DONE', 'REJECTED');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT,
"role" "Role" NOT NULL DEFAULT 'ADMIN',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CommissionType" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"basePrice" DOUBLE PRECISION NOT NULL,
"deliveryEst" TEXT,
"tags" TEXT[],
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CommissionType_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CommissionRequest" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"message" TEXT NOT NULL,
"typeId" TEXT NOT NULL,
"status" "RequestStatus" NOT NULL DEFAULT 'PENDING',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CommissionRequest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Artwork" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"imageUrl" TEXT NOT NULL,
"description" TEXT,
"tags" TEXT[],
"formats" TEXT[],
"isPublic" BOOLEAN NOT NULL DEFAULT true,
"groupId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Artwork_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PresentationGroup" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PresentationGroup_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Preferences" (
"id" TEXT NOT NULL,
"commissionOpen" BOOLEAN NOT NULL DEFAULT true,
"defaultDelivery" TEXT,
"autoReplyMessage" TEXT,
"notifyByEmail" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "Preferences_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TOS" (
"id" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TOS_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- AddForeignKey
ALTER TABLE "CommissionRequest" ADD CONSTRAINT "CommissionRequest_typeId_fkey" FOREIGN KEY ("typeId") REFERENCES "CommissionType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Artwork" ADD CONSTRAINT "Artwork_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "PresentationGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@ -13,3 +13,84 @@ datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
role Role @default(ADMIN)
createdAt DateTime @default(now())
}
enum Role {
ADMIN
ARTIST
}
model CommissionType {
id String @id @default(cuid())
title String
description String?
basePrice Float
deliveryEst String? // e.g. "2 weeks"
tags String[] // e.g. shaded, sketch, full-body
active Boolean @default(true)
createdAt DateTime @default(now())
CommissionRequest CommissionRequest[]
}
model CommissionRequest {
id String @id @default(cuid())
name String
email String
message String
typeId String
status RequestStatus @default(PENDING)
createdAt DateTime @default(now())
type CommissionType @relation(fields: [typeId], references: [id])
}
enum RequestStatus {
PENDING
ACCEPTED
IN_PROGRESS
DONE
REJECTED
}
model Artwork {
id String @id @default(cuid())
title String
imageUrl String
description String?
tags String[]
formats String[]
isPublic Boolean @default(true)
groupId String?
createdAt DateTime @default(now())
group PresentationGroup? @relation(fields: [groupId], references: [id])
}
model PresentationGroup {
id String @id @default(cuid())
name String
description String?
createdAt DateTime @default(now())
Artwork Artwork[]
}
model Preferences {
id String @id @default(cuid())
commissionOpen Boolean @default(true)
defaultDelivery String? // e.g. "7 days"
autoReplyMessage String?
notifyByEmail Boolean @default(true)
}
model TOS {
id String @id @default(cuid())
content String // Markdown or rich text
createdAt DateTime @default(now())
}

View File

@ -0,0 +1,10 @@
import { KanbanBoard } from "@/components/kanban/KanbanBoard"
export default function KanbanPage() {
return (
<div className="p-4">
<h1 className="text-2xl font-bold mb-4">Commission Queue</h1>
<KanbanBoard />
</div>
)
}

View File

@ -0,0 +1,52 @@
"use client"
import { mockCards } from "@/data/mockCards"
import type { KanbanCardData } from "@/types/kanban"
import { DndContext, DragEndEvent, closestCenter } from "@dnd-kit/core"
import { useState } from "react"
import { KanbanColumn } from "./KanbanColumn"
export type Status = "PENDING" | "ACCEPTED" | "IN_PROGRESS" | "DONE" | "REJECTED"
const columns: Status[] = ["PENDING", "ACCEPTED", "IN_PROGRESS", "DONE", "REJECTED"]
export function KanbanBoard() {
const [cards, setCards] = useState<Record<Status, KanbanCardData[]>>(mockCards)
function findColumnByCardId(cardId: string): Status | null {
for (const status of columns) {
if (cards[status].some((c) => c.id === cardId)) return status
}
return null
}
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (!active || !over || active.id === over.id) return
const from = findColumnByCardId(active.id as string)
const to = over.id as Status
if (!from || !to || from === to) return
const card = cards[from].find((c) => c.id === active.id)
if (!card) return
setCards((prev) => {
return {
...prev,
[from]: prev[from].filter((c) => c.id !== card.id),
[to]: [card, ...prev[to]],
}
})
}
return (
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-4">
{columns.map((status) => (
<KanbanColumn key={status} status={status} items={cards[status]} />
))}
</div>
</DndContext>
)
}

View File

@ -0,0 +1,55 @@
"use client"
import { Dialog, DialogContent } from "@/components/ui/dialog"
import type { KanbanCardData } from "@/types/kanban"
export interface KanbanCardModalProps {
card: KanbanCardData | null
onClose: () => void
}
export function KanbanCardModal({ card, onClose }: KanbanCardModalProps) {
return (
<Dialog open={!!card} onOpenChange={onClose}>
<DialogContent>
{card && (
<div className="space-y-4">
<h3 className="text-xl font-semibold">{card.title}</h3>
<div className="flex gap-2 flex-wrap">
{card.labels?.map((label) => (
<span
key={label.id}
className="text-xs px-2 py-1 rounded-full"
style={{ backgroundColor: label.color }}
>
{label.name}
</span>
))}
</div>
{card.description && (
<p className="text-sm text-muted-foreground">{card.description}</p>
)}
{card.todos?.length > 0 && (
<div className="space-y-1">
{card.todos.map((todo) => (
<div key={todo.id} className="flex items-center gap-2">
<input type="checkbox" checked={todo.done} readOnly />
<span
className={`text-sm ${todo.done ? "line-through text-muted-foreground" : ""
}`}
>
{todo.text}
</span>
</div>
))}
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,58 @@
"use client"
import type { KanbanCardData } from "@/types/kanban"
import { useDraggable } from "@dnd-kit/core"
interface KanbanCardPreviewProps extends KanbanCardData {
onClick: () => void
}
export function KanbanCardPreview({
id,
title,
labels = [],
todos = [],
onClick,
}: KanbanCardPreviewProps) {
const { setNodeRef, attributes, listeners, transform, isDragging } = useDraggable({
id,
})
const done = todos.filter((t) => t.done).length
const total = todos.length
return (
<div
ref={setNodeRef}
{...listeners}
{...attributes}
onClick={onClick}
className={`rounded-md bg-white dark:bg-zinc-900 border border-border p-3 shadow-sm cursor-pointer hover:ring-2 hover:ring-primary/40 transition ${isDragging ? "opacity-50" : ""
}`}
style={{
transform: transform
? `translate(${transform.x}px, ${transform.y}px)`
: undefined,
}}
>
<div className="flex gap-1 mb-2 flex-wrap">
{labels.map((label) => (
<span
key={label.id}
className="w-3 h-3 rounded-full"
style={{ backgroundColor: label.color }}
title={label.name}
/>
))}
</div>
<div className="font-medium text-sm mb-1">{title}</div>
{total > 0 && (
<div className="text-xs text-muted-foreground">
{done} / {total} tasks done
</div>
)}
</div>
)
}

View File

@ -0,0 +1,38 @@
"use client"
import type { KanbanCardData } from "@/types/kanban"
import { useDroppable } from "@dnd-kit/core"
import { useState } from "react"
import type { Status } from "./KanbanBoard"
import { KanbanCardModal } from "./KanbanCardModal"
import { KanbanCardPreview } from "./KanbanCardPreview"
interface KanbanColumnProps {
status: Status
items: KanbanCardData[]
}
export function KanbanColumn({ status, items }: KanbanColumnProps) {
const { setNodeRef } = useDroppable({ id: status })
const [activeCard, setActiveCard] = useState<KanbanCardData | null>(null)
return (
<div
ref={setNodeRef}
className="bg-muted rounded-lg p-2 flex flex-col gap-2 min-h-[200px]"
>
<h2 className="text-lg font-semibold text-center mb-2">
{status.replace("_", " ")}
</h2>
{items.map((item) => (
<KanbanCardPreview
key={item.id}
{...item}
onClick={() => setActiveCard(item)}
/>
))}
<KanbanCardModal card={activeCard} onClose={() => setActiveCard(null)} />
</div>
)
}

View File

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

124
src/data/mockCards.ts Normal file
View File

@ -0,0 +1,124 @@
import type { KanbanCardData } from "@/types/kanban"
export const mockCards: Record<string, KanbanCardData[]> = {
PENDING: [
{
id: "card-1",
title: "New Commission Request",
description: "A shaded full body request with background.",
labels: [
{ id: "l1", name: "Full Body", color: "#6366f1" },
{ id: "l2", name: "Shaded", color: "#10b981" },
],
todos: [
{ id: "t1", text: "Confirm client", done: true },
{ id: "t2", text: "Send invoice", done: false },
{ id: "t3", text: "Start sketch", done: false },
],
},
{
id: "c1",
title: "Initial sketch",
description: "Draft the sketch layout for the dragon commission.",
labels: [{ id: "l1", name: "Sketch", color: "#7c3aed" }],
todos: [
{ id: "t1", text: "Outline basic pose", done: true },
{ id: "t2", text: "Add background elements", done: false },
],
},
{
id: "c6",
title: "Color palette ideas",
description: "Brainstorm palette options for upcoming sticker set.",
labels: [{ id: "l3", name: "Palette", color: "#f59e0b" }],
todos: [
{ id: "t11", text: "Collect 5 references", done: true },
{ id: "t12", text: "Make base color variants", done: false },
],
},
],
ACCEPTED: [
{
id: "c2",
title: "Full body sketch (Fox)",
description: "Accepted commission from Alex, full body fox.",
labels: [
{ id: "l2", name: "Commission", color: "#10b981" },
{ id: "l3", name: "Lineart", color: "#3b82f6" },
],
todos: [
{ id: "t3", text: "Line pose", done: false },
{ id: "t4", text: "Inking details", done: false },
],
},
{
id: "c7",
title: "Badge template layout",
description: "Design a reusable layout for convention badges.",
labels: [{ id: "l6", name: "Template", color: "#a855f7" }],
todos: [
{ id: "t13", text: "Vertical layout sketch", done: true },
{ id: "t14", text: "Add social name field", done: false },
],
},
],
IN_PROGRESS: [
{
id: "c3",
title: "Shaded portrait (Wolf)",
description: "Working on detailed portrait with complex lighting.",
labels: [
{ id: "l4", name: "Portrait", color: "#f97316" },
{ id: "l5", name: "Shaded", color: "#6366f1" },
],
todos: [
{ id: "t5", text: "Refine shadows", done: false },
{ id: "t6", text: "Color correction", done: false },
],
},
{
id: "c8",
title: "Telegram stickers",
description: "Finish final 2 stickers for the feline set.",
labels: [
{ id: "l1", name: "Sticker", color: "#84cc16" },
{ id: "l5", name: "Flat", color: "#eab308" },
],
todos: [
{ id: "t15", text: "Render #5", done: true },
{ id: "t16", text: "Render #6", done: false },
],
},
],
DONE: [
{
id: "c4",
title: "Refsheet (Tiger)",
description: "Complete refsheet for character submission.",
labels: [{ id: "l6", name: "Refsheet", color: "#ec4899" }],
todos: [
{ id: "t7", text: "Label layers", done: true },
{ id: "t8", text: "Export PNG", done: true },
],
},
{
id: "c9",
title: "Artfight entry",
description: "Submitted and posted Artfight attack.",
labels: [{ id: "l7", name: "Artfight", color: "#0ea5e9" }],
todos: [
{ id: "t17", text: "Add watermark", done: true },
{ id: "t18", text: "Post to site", done: true },
],
},
],
REJECTED: [
{
id: "c5",
title: "NSFW icon (cancelled)",
description: "Client cancelled due to budget.",
labels: [{ id: "l8", name: "NSFW", color: "#ef4444" }],
todos: [],
},
],
}

21
src/types/kanban.ts Normal file
View File

@ -0,0 +1,21 @@
// src/types/kanban.ts
export type KanbanLabel = {
id: string
name: string
color: string // e.g. "red", "green", "blue"
}
export type KanbanTodo = {
id: string
text: string
done: boolean
}
export type KanbanCardData = {
id: string
title: string
labels: KanbanLabel[]
description: string
todos: KanbanTodo[]
}