From ffda65490fa64c948ba0ef255b3fa0cf6a4e0abf Mon Sep 17 00:00:00 2001 From: janpeter visser Date: Fri, 24 Apr 2026 11:33:47 +0200 Subject: [PATCH] feat: ST-101-ST-110 M1 producten, PBI backlog, iconen en PWA manifest - Product aanmaken/bewerken/archiveren/herstellen (ST-101, ST-103) - SplitPane component met versleepbare splitter en localStorage (ST-104) - PanelNavBar herbruikbaar paneelheader component (ST-105) - PbiList met prioriteitsgroepen, inline aanmaken, filter en verwijderen (ST-106-ST-110) - StoryPanel placeholder rechter paneel met selectie via Zustand (ST-109) - App iconen geinstalleerd: favicon, apple-icon, PWA manifest (192/512px) - AppIcon SVG component in navigatiebar - Root layout metadata bijgewerkt naar Nederlands Co-Authored-By: Claude Sonnet 4.6 --- actions/pbis.ts | 112 +++++++++ actions/products.ts | 139 ++++++++++ app/(app)/dashboard/page.tsx | 35 ++- app/(app)/products/[id]/page.tsx | 80 ++++++ app/(app)/products/[id]/settings/page.tsx | 62 +++++ app/(app)/products/new/page.tsx | 18 ++ app/apple-icon.png | Bin 0 -> 11527 bytes app/favicon.ico | Bin 25931 -> 569 bytes app/icon.png | Bin 0 -> 12479 bytes app/layout.tsx | 19 +- components/backlog/pbi-list.tsx | 237 ++++++++++++++++++ components/backlog/story-panel.tsx | 56 +++++ components/dashboard/product-list.tsx | 61 +++-- .../products/archive-product-button.tsx | 54 ++++ components/products/product-form.tsx | 135 ++++++++++ components/shared/app-icon.tsx | 70 ++++++ components/shared/nav-bar.tsx | 2 + components/shared/panel-nav-bar.tsx | 16 ++ components/split-pane/split-pane.tsx | 125 +++++++++ public/icon-192.png | Bin 0 -> 12479 bytes public/icon-512.png | Bin 0 -> 40532 bytes public/manifest.json | 23 ++ stores/selection-store.ts | 11 + 23 files changed, 1229 insertions(+), 26 deletions(-) create mode 100644 actions/pbis.ts create mode 100644 actions/products.ts create mode 100644 app/(app)/products/[id]/page.tsx create mode 100644 app/(app)/products/[id]/settings/page.tsx create mode 100644 app/(app)/products/new/page.tsx create mode 100644 app/apple-icon.png create mode 100644 app/icon.png create mode 100644 components/backlog/pbi-list.tsx create mode 100644 components/backlog/story-panel.tsx create mode 100644 components/products/archive-product-button.tsx create mode 100644 components/products/product-form.tsx create mode 100644 components/shared/app-icon.tsx create mode 100644 components/shared/panel-nav-bar.tsx create mode 100644 components/split-pane/split-pane.tsx create mode 100644 public/icon-192.png create mode 100644 public/icon-512.png create mode 100644 public/manifest.json create mode 100644 stores/selection-store.ts diff --git a/actions/pbis.ts b/actions/pbis.ts new file mode 100644 index 0000000..dee93cb --- /dev/null +++ b/actions/pbis.ts @@ -0,0 +1,112 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' +import { z } from 'zod' +import { prisma } from '@/lib/prisma' +import { SessionData, sessionOptions } from '@/lib/session' + +async function getSession() { + return getIronSession(await cookies(), sessionOptions) +} + +async function verifyProductOwnership(productId: string, userId: string) { + return prisma.product.findFirst({ where: { id: productId, user_id: userId } }) +} + +const createPbiSchema = z.object({ + productId: z.string(), + title: z.string().min(1, 'Titel is verplicht').max(200), + priority: z.coerce.number().int().min(1).max(4), +}) + +const updatePbiSchema = z.object({ + id: z.string(), + title: z.string().min(1, 'Titel is verplicht').max(200), + description: z.string().max(2000).optional(), + priority: z.coerce.number().int().min(1).max(4), +}) + +export async function createPbiAction(_prevState: unknown, formData: FormData) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const parsed = createPbiSchema.safeParse({ + productId: formData.get('productId'), + title: formData.get('title'), + priority: formData.get('priority'), + }) + if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } + + const product = await verifyProductOwnership(parsed.data.productId, session.userId) + if (!product) return { error: 'Product niet gevonden' } + + const last = await prisma.pbi.findFirst({ + where: { product_id: parsed.data.productId, priority: parsed.data.priority }, + orderBy: { sort_order: 'desc' }, + }) + const sort_order = (last?.sort_order ?? 0) + 1.0 + + const pbi = await prisma.pbi.create({ + data: { + product_id: parsed.data.productId, + title: parsed.data.title, + priority: parsed.data.priority, + sort_order, + }, + }) + + revalidatePath(`/products/${parsed.data.productId}`) + return { success: true, pbi } +} + +export async function updatePbiAction(_prevState: unknown, formData: FormData) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const parsed = updatePbiSchema.safeParse({ + id: formData.get('id'), + title: formData.get('title'), + description: formData.get('description') || undefined, + priority: formData.get('priority'), + }) + if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } + + const pbi = await prisma.pbi.findFirst({ + where: { id: parsed.data.id }, + include: { product: true }, + }) + if (!pbi || pbi.product.user_id !== session.userId) return { error: 'PBI niet gevonden' } + + await prisma.pbi.update({ + where: { id: parsed.data.id }, + data: { + title: parsed.data.title, + description: parsed.data.description ?? null, + priority: parsed.data.priority, + }, + }) + + revalidatePath(`/products/${pbi.product_id}`) + return { success: true } +} + +export async function deletePbiAction(id: string) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const pbi = await prisma.pbi.findFirst({ + where: { id }, + include: { product: true }, + }) + if (!pbi || pbi.product.user_id !== session.userId) return { error: 'PBI niet gevonden' } + + await prisma.pbi.delete({ where: { id } }) + + revalidatePath(`/products/${pbi.product_id}`) + return { success: true } +} diff --git a/actions/products.ts b/actions/products.ts new file mode 100644 index 0000000..1424b74 --- /dev/null +++ b/actions/products.ts @@ -0,0 +1,139 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { redirect } from 'next/navigation' +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' +import { z } from 'zod' +import { prisma } from '@/lib/prisma' +import { SessionData, sessionOptions } from '@/lib/session' + +const productSchema = z.object({ + name: z.string().min(1, 'Naam is verplicht').max(100, 'Naam mag maximaal 100 tekens bevatten'), + description: z.string().max(1000, 'Beschrijving mag maximaal 1000 tekens bevatten').optional(), + repo_url: z + .string() + .url('Voer een geldige URL in (inclusief https://)') + .optional() + .or(z.literal('')), + definition_of_done: z + .string() + .min(1, 'Definition of Done is verplicht') + .max(500, 'Definition of Done mag maximaal 500 tekens bevatten'), +}) + +async function getSession() { + return getIronSession(await cookies(), sessionOptions) +} + +export async function createProductAction(_prevState: unknown, formData: FormData) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const parsed = productSchema.safeParse({ + name: formData.get('name'), + description: formData.get('description') || undefined, + repo_url: formData.get('repo_url') || undefined, + definition_of_done: formData.get('definition_of_done'), + }) + + if (!parsed.success) { + return { error: parsed.error.flatten().fieldErrors } + } + + const existing = await prisma.product.findFirst({ + where: { user_id: session.userId, name: parsed.data.name }, + }) + if (existing) return { error: { name: ['Een product met deze naam bestaat al'] } } + + const product = await prisma.product.create({ + data: { + user_id: session.userId, + name: parsed.data.name, + description: parsed.data.description ?? null, + repo_url: parsed.data.repo_url || null, + definition_of_done: parsed.data.definition_of_done, + }, + }) + + redirect(`/products/${product.id}`) +} + +export async function updateProductAction(_prevState: unknown, formData: FormData) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const id = formData.get('id') as string + if (!id) return { error: 'Product niet gevonden' } + + const parsed = productSchema.safeParse({ + name: formData.get('name'), + description: formData.get('description') || undefined, + repo_url: formData.get('repo_url') || undefined, + definition_of_done: formData.get('definition_of_done'), + }) + + if (!parsed.success) { + return { error: parsed.error.flatten().fieldErrors } + } + + // Verify ownership + const product = await prisma.product.findFirst({ + where: { id, user_id: session.userId }, + }) + if (!product) return { error: 'Product niet gevonden' } + + // Check unique name (excluding self) + const duplicate = await prisma.product.findFirst({ + where: { user_id: session.userId, name: parsed.data.name, NOT: { id } }, + }) + if (duplicate) return { error: { name: ['Een product met deze naam bestaat al'] } } + + await prisma.product.update({ + where: { id }, + data: { + name: parsed.data.name, + description: parsed.data.description ?? null, + repo_url: parsed.data.repo_url || null, + definition_of_done: parsed.data.definition_of_done, + }, + }) + + revalidatePath(`/products/${id}`) + revalidatePath('/dashboard') + return { success: true } +} + +export async function archiveProductAction(id: string) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const product = await prisma.product.findFirst({ + where: { id, user_id: session.userId }, + }) + if (!product) return { error: 'Product niet gevonden' } + + await prisma.product.update({ where: { id }, data: { archived: true } }) + + revalidatePath('/dashboard') + redirect('/dashboard') +} + +export async function restoreProductAction(id: string) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const product = await prisma.product.findFirst({ + where: { id, user_id: session.userId }, + }) + if (!product) return { error: 'Product niet gevonden' } + + await prisma.product.update({ where: { id }, data: { archived: false } }) + + revalidatePath('/dashboard') + return { success: true } +} diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx index 8893480..e4f675c 100644 --- a/app/(app)/dashboard/page.tsx +++ b/app/(app)/dashboard/page.tsx @@ -6,24 +6,47 @@ import Link from 'next/link' import { Button } from '@/components/ui/button' import { ProductList } from '@/components/dashboard/product-list' -export default async function DashboardPage() { +interface Props { + searchParams: Promise<{ archived?: string }> +} + +export default async function DashboardPage({ searchParams }: Props) { const session = await getIronSession(await cookies(), sessionOptions) + const { archived } = await searchParams + const showArchived = archived === '1' const products = await prisma.product.findMany({ - where: { user_id: session.userId, archived: false }, + where: { user_id: session.userId, archived: showArchived }, orderBy: { created_at: 'desc' }, }) return (
-

Mijn Producten

- {!session.isDemo && ( - +
+

+ {showArchived ? 'Gearchiveerde producten' : 'Mijn Producten'} +

+ {showArchived ? ( + + ← Actief + + ) : ( + + Toon gearchiveerd + + )} +
+ {!session.isDemo && !showArchived && ( + )}
- +
) } diff --git a/app/(app)/products/[id]/page.tsx b/app/(app)/products/[id]/page.tsx new file mode 100644 index 0000000..3975f1f --- /dev/null +++ b/app/(app)/products/[id]/page.tsx @@ -0,0 +1,80 @@ +import { notFound } from 'next/navigation' +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' +import { SessionData, sessionOptions } from '@/lib/session' +import { prisma } from '@/lib/prisma' +import { SplitPane } from '@/components/split-pane/split-pane' +import { PbiList } from '@/components/backlog/pbi-list' +import { StoryPanel } from '@/components/backlog/story-panel' + +interface Props { + params: Promise<{ id: string }> +} + +export default async function ProductBacklogPage({ params }: Props) { + const { id } = await params + const session = await getIronSession(await cookies(), sessionOptions) + + const product = await prisma.product.findFirst({ + where: { id, user_id: session.userId }, + }) + if (!product) notFound() + + const pbis = await prisma.pbi.findMany({ + where: { product_id: id }, + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + }) + + const stories = await prisma.story.findMany({ + where: { product_id: id }, + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + select: { id: true, title: true, status: true, pbi_id: true }, + }) + + // Group stories by PBI id + const storiesByPbi: Record = {} + for (const story of stories) { + if (!storiesByPbi[story.pbi_id]) storiesByPbi[story.pbi_id] = [] + storiesByPbi[story.pbi_id].push(story) + } + + return ( +
+ {/* Product header */} +
+
+

{product.name}

+ {product.description && ( +

{product.description}

+ )} +
+ + Instellingen + +
+ + {/* Split pane */} +
+ ({ id: p.id, title: p.title, priority: p.priority }))} + isDemo={session.isDemo ?? false} + /> + } + right={ + + } + /> +
+
+ ) +} diff --git a/app/(app)/products/[id]/settings/page.tsx b/app/(app)/products/[id]/settings/page.tsx new file mode 100644 index 0000000..5095bef --- /dev/null +++ b/app/(app)/products/[id]/settings/page.tsx @@ -0,0 +1,62 @@ +import { notFound, redirect } from 'next/navigation' +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' +import { SessionData, sessionOptions } from '@/lib/session' +import { prisma } from '@/lib/prisma' +import { ProductForm } from '@/components/products/product-form' +import { ArchiveProductButton } from '@/components/products/archive-product-button' +import { updateProductAction } from '@/actions/products' +import Link from 'next/link' + +interface Props { + params: Promise<{ id: string }> +} + +export default async function ProductSettingsPage({ params }: Props) { + const { id } = await params + const session = await getIronSession(await cookies(), sessionOptions) + + if (session.isDemo) redirect(`/products/${id}`) + + const product = await prisma.product.findFirst({ + where: { id, user_id: session.userId }, + }) + if (!product) notFound() + + return ( +
+
+ + ← {product.name} + + / +

Instellingen

+
+ + + +
+

Gevaarlijke zone

+
+
+

Product archiveren

+

+ Het product wordt verborgen uit het dashboard. Je kunt het later herstellen. +

+
+ +
+
+
+ ) +} diff --git a/app/(app)/products/new/page.tsx b/app/(app)/products/new/page.tsx new file mode 100644 index 0000000..ba768a7 --- /dev/null +++ b/app/(app)/products/new/page.tsx @@ -0,0 +1,18 @@ +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' +import { redirect } from 'next/navigation' +import { SessionData, sessionOptions } from '@/lib/session' +import { ProductForm } from '@/components/products/product-form' +import { createProductAction } from '@/actions/products' + +export default async function NewProductPage() { + const session = await getIronSession(await cookies(), sessionOptions) + if (session.isDemo) redirect('/dashboard') + + return ( +
+

Nieuw product

+ +
+ ) +} diff --git a/app/apple-icon.png b/app/apple-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..628e71391861d7d3c30a1d64188bde0a8a290b68 GIT binary patch literal 11527 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 LrJugUA?W`A8`#=m diff --git a/app/icon.png b/app/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..79fd6ec949f18f2376dfb6a61537b8f3a003a3a9 GIT binary patch literal 12479 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 P5&-fam1QcWz6AY0>*6rW literal 0 HcmV?d00001 diff --git a/app/layout.tsx b/app/layout.tsx index 976eb90..fabf779 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -13,8 +13,21 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: { + default: "Scrum4Me", + template: "%s — Scrum4Me", + }, + description: "Lichtgewicht Scrum-planner voor solo developers en kleine teams", + icons: { + icon: [ + { url: "/favicon.ico", sizes: "48x48" }, + { url: "/icon.png", sizes: "192x192", type: "image/png" }, + ], + apple: [ + { url: "/apple-icon.png", sizes: "180x180", type: "image/png" }, + ], + }, + manifest: "/manifest.json", }; export default function RootLayout({ @@ -24,7 +37,7 @@ export default function RootLayout({ }>) { return ( {children} diff --git a/components/backlog/pbi-list.tsx b/components/backlog/pbi-list.tsx new file mode 100644 index 0000000..011058e --- /dev/null +++ b/components/backlog/pbi-list.tsx @@ -0,0 +1,237 @@ +'use client' + +import { useState, useTransition } from 'react' +import { useActionState } from 'react' +import { useFormStatus } from 'react-dom' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Badge } from '@/components/ui/badge' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { PanelNavBar } from '@/components/shared/panel-nav-bar' +import { useSelectionStore } from '@/stores/selection-store' +import { createPbiAction, deletePbiAction } from '@/actions/pbis' +import { cn } from '@/lib/utils' + +const PRIORITY_LABELS: Record = { + 1: 'Kritiek', + 2: 'Hoog', + 3: 'Gemiddeld', + 4: 'Laag', +} + +const PRIORITY_COLORS: Record = { + 1: 'bg-priority-critical/15 text-priority-critical border-priority-critical/30', + 2: 'bg-priority-high/15 text-priority-high border-priority-high/30', + 3: 'bg-priority-medium/15 text-priority-medium border-priority-medium/30', + 4: 'bg-priority-low/15 text-priority-low border-priority-low/30', +} + +interface Pbi { + id: string + title: string + priority: number +} + +interface PbiListProps { + productId: string + pbis: Pbi[] + isDemo: boolean +} + +function CreatePbiForm({ productId, priority, onDone }: { productId: string; priority: number; onDone: () => void }) { + const [state, formAction] = useActionState( + async (_prev: unknown, fd: FormData) => { + const result = await createPbiAction(_prev, fd) + if (result?.success) onDone() + return result + }, + undefined + ) + const error = state?.error + + return ( +
+ + + + + + {typeof error === 'string' && ( +

{error}

+ )} + + ) +} + +function CreateSubmitButton() { + const { pending } = useFormStatus() + return ( + + ) +} + +export function PbiList({ productId, pbis, isDemo }: PbiListProps) { + const { selectedPbiId, selectPbi } = useSelectionStore() + const [filterPriority, setFilterPriority] = useState(null) + const [creatingForPriority, setCreatingForPriority] = useState(null) + const [, startTransition] = useTransition() + + const filtered = filterPriority ? pbis.filter(p => p.priority === filterPriority) : pbis + + const grouped = [1, 2, 3, 4].reduce>((acc, p) => { + acc[p] = filtered.filter(pbi => pbi.priority === p) + return acc + }, {} as Record) + + const visiblePriorities = [1, 2, 3, 4].filter( + p => grouped[p].length > 0 || creatingForPriority === p + ) + + function handleDelete(id: string) { + startTransition(async () => { + await deletePbiAction(id) + if (selectedPbiId === id) selectPbi(null) + }) + } + + return ( +
+ + {filterPriority !== null && ( + + )} + + {!isDemo && ( + + )} + + } + /> + +
+ {pbis.length === 0 && creatingForPriority === null ? ( +
+

Nog geen PBI's aangemaakt.

+ {!isDemo && ( + + )} +
+ ) : ( +
+ {visiblePriorities.map(priority => ( +
+ {/* Priority group header */} +
+ + {PRIORITY_LABELS[priority]} + +
+ {!isDemo && ( + + )} +
+ + {/* PBI items */} + {grouped[priority].map(pbi => ( +
selectPbi(pbi.id)} + className={cn( + 'group flex items-center justify-between px-4 py-2 cursor-pointer transition-colors hover:bg-surface-container', + selectedPbiId === pbi.id && 'bg-primary-container text-primary-container-foreground' + )} + > + {pbi.title} + {!isDemo && ( + + )} +
+ ))} + + {/* Inline create form for this priority */} + {creatingForPriority === priority && ( + setCreatingForPriority(null)} + /> + )} +
+ ))} + + {/* If creating for a priority that has no items yet and isn't in visiblePriorities */} + {creatingForPriority !== null && !visiblePriorities.includes(creatingForPriority) && ( +
+
+ + {PRIORITY_LABELS[creatingForPriority]} + +
+
+ setCreatingForPriority(null)} + /> +
+ )} +
+ )} +
+
+ ) +} diff --git a/components/backlog/story-panel.tsx b/components/backlog/story-panel.tsx new file mode 100644 index 0000000..4adb86f --- /dev/null +++ b/components/backlog/story-panel.tsx @@ -0,0 +1,56 @@ +'use client' + +import { useSelectionStore } from '@/stores/selection-store' +import { PanelNavBar } from '@/components/shared/panel-nav-bar' + +interface Story { + id: string + title: string + status: string +} + +interface StoryPanelProps { + storiesByPbi: Record + isDemo: boolean +} + +export function StoryPanel({ storiesByPbi, isDemo }: StoryPanelProps) { + const { selectedPbiId } = useSelectionStore() + + const stories = selectedPbiId ? (storiesByPbi[selectedPbiId] ?? []) : null + + return ( +
+ + Story + ) : undefined + } + /> +
+ {stories === null ? ( +

+ Selecteer een PBI om de stories te bekijken. +

+ ) : stories.length === 0 ? ( +

+ Nog geen stories voor dit PBI. +

+ ) : ( +
+ {stories.map(story => ( +
+ {story.title} +
+ ))} +
+ )} +
+
+ ) +} diff --git a/components/dashboard/product-list.tsx b/components/dashboard/product-list.tsx index 3099d5b..3571dc2 100644 --- a/components/dashboard/product-list.tsx +++ b/components/dashboard/product-list.tsx @@ -2,7 +2,9 @@ import Link from 'next/link' import { useRouter } from 'next/navigation' +import { useTransition } from 'react' import { Button } from '@/components/ui/button' +import { restoreProductAction } from '@/actions/products' interface Product { id: string @@ -14,17 +16,30 @@ interface Product { interface ProductListProps { products: Product[] isDemo: boolean + showArchived?: boolean } -export function ProductList({ products, isDemo }: ProductListProps) { +export function ProductList({ products, isDemo, showArchived = false }: ProductListProps) { const router = useRouter() + const [, startTransition] = useTransition() + + function handleRestore(id: string) { + startTransition(async () => { + await restoreProductAction(id) + router.refresh() + }) + } if (products.length === 0) { return (
-

Je hebt nog geen producten aangemaakt.

- {!isDemo && ( - )} @@ -37,8 +52,10 @@ export function ProductList({ products, isDemo }: ProductListProps) { {products.map(product => (
router.push(`/products/${product.id}`)} - className="group cursor-pointer bg-surface-container-low border border-border rounded-xl p-4 hover:border-primary transition-colors" + onClick={() => !showArchived && router.push(`/products/${product.id}`)} + className={`group bg-surface-container-low border border-border rounded-xl p-4 transition-colors ${ + showArchived ? 'opacity-60' : 'cursor-pointer hover:border-primary' + }`} >
@@ -51,17 +68,27 @@ export function ProductList({ products, isDemo }: ProductListProps) {

)}
- {product.repo_url && ( - e.stopPropagation()} - className="text-xs text-muted-foreground hover:text-primary shrink-0 underline" - > - Repo - - )} +
+ {product.repo_url && ( + e.stopPropagation()} + className="text-xs text-muted-foreground hover:text-primary underline" + > + Repo + + )} + {showArchived && !isDemo && ( + + )} +
))} diff --git a/components/products/archive-product-button.tsx b/components/products/archive-product-button.tsx new file mode 100644 index 0000000..a083e77 --- /dev/null +++ b/components/products/archive-product-button.tsx @@ -0,0 +1,54 @@ +'use client' + +import { useState, useTransition } from 'react' +import { Button } from '@/components/ui/button' +import { archiveProductAction } from '@/actions/products' + +interface ArchiveProductButtonProps { + productId: string +} + +export function ArchiveProductButton({ productId }: ArchiveProductButtonProps) { + const [confirming, setConfirming] = useState(false) + const [isPending, startTransition] = useTransition() + + function handleArchive() { + startTransition(async () => { + await archiveProductAction(productId) + }) + } + + if (confirming) { + return ( +
+ + +
+ ) + } + + return ( + + ) +} diff --git a/components/products/product-form.tsx b/components/products/product-form.tsx new file mode 100644 index 0000000..f8910df --- /dev/null +++ b/components/products/product-form.tsx @@ -0,0 +1,135 @@ +'use client' + +import { useActionState } from 'react' +import { useFormStatus } from 'react-dom' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' + +type FieldErrors = Record +type ActionResult = { error?: string | FieldErrors; success?: boolean } | undefined + +function SubmitButton({ label }: { label: string }) { + const { pending } = useFormStatus() + return ( + + ) +} + +function getFieldError(error: string | FieldErrors | undefined, field: string): string | undefined { + if (!error || typeof error === 'string') return undefined + return (error as FieldErrors)[field]?.[0] +} + +function getGlobalError(error: string | FieldErrors | undefined): string | undefined { + if (typeof error === 'string') return error + return undefined +} + +interface ProductFormProps { + action: (_prevState: unknown, formData: FormData) => Promise + submitLabel: string + defaultValues?: { + id?: string + name?: string + description?: string + repo_url?: string + definition_of_done?: string + } +} + +export function ProductForm({ action, submitLabel, defaultValues }: ProductFormProps) { + const [state, formAction] = useActionState(action, undefined) + + const fieldError = (field: string) => getFieldError(state?.error, field) + const globalError = getGlobalError(state?.error) + + return ( +
+ {defaultValues?.id && ( + + )} + +
+ + + {fieldError('name') && ( +

{fieldError('name')}

+ )} +
+ +
+ +