Theme 3: download a product as a project (zip export)

New GET /api/orgboard/products/{id}/export streams a zip of the product's
delivered work: PRODUCT.md (identity), each team's artifacts written as real
source files when the artifact is a single fenced code block (App.tsx,
schema.sql, …) or markdown otherwise, plus a README manifest. Gated on
board-view permission. The Delivery dashboard gets a Download project button
that fetches the file with the auth header and saves it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-17 07:30:37 +03:30
parent 1e33d57b4e
commit 0658061580
3 changed files with 201 additions and 11 deletions
+48 -11
View File
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Gauge } from 'lucide-react'
import { Download, Gauge } from 'lucide-react'
import { toast } from 'sonner'
import { AppShell } from '@/components/AppShell'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
@@ -59,6 +60,7 @@ export function DeliveryPage() {
const [productId, setProductId] = useState<string | null>(() => localStorage.getItem('teamup.delivery.product'))
const [teams, setTeams] = useState<TeamProgress[]>([])
const [analytics, setAnalytics] = useState<Analytics | null>(null)
const [downloading, setDownloading] = useState(false)
useEffect(() => {
if (!organizationId) return
@@ -122,6 +124,33 @@ export function DeliveryPage() {
const product = products.find((p) => p.id === productId) ?? null
// Download the product as a zip of its delivered artifacts. The export endpoint streams a file, so we
// fetch it with the auth header (the api helper only does JSON), then trigger a browser download.
const downloadProject = useCallback(async () => {
if (!productId) return
setDownloading(true)
try {
const token = useAuth.getState().token
const res = await fetch(`/api/orgboard/products/${productId}/export`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!res.ok) throw new Error(`Export failed (${res.status})`)
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${(product?.name ?? 'project').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}.zip`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
} catch (err) {
toast.error((err as Error).message)
} finally {
setDownloading(false)
}
}, [productId, product])
return (
<AppShell>
<div className="mx-auto max-w-4xl p-6">
@@ -129,16 +158,24 @@ export function DeliveryPage() {
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<Gauge className="size-6" /> Delivery
</h1>
{products.length > 0 && (
<Select value={productId ?? ''} onValueChange={setProductId}>
<SelectTrigger className="w-56"><SelectValue placeholder="Pick a product" /></SelectTrigger>
<SelectContent>
<SelectGroup>
{products.map((p) => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
</SelectGroup>
</SelectContent>
</Select>
)}
<div className="flex items-center gap-2">
{product && (
<Button variant="outline" size="sm" disabled={downloading} onClick={downloadProject}>
<Download data-icon="inline-start" />
{downloading ? 'Preparing…' : 'Download project'}
</Button>
)}
{products.length > 0 && (
<Select value={productId ?? ''} onValueChange={setProductId}>
<SelectTrigger className="w-56"><SelectValue placeholder="Pick a product" /></SelectTrigger>
<SelectContent>
<SelectGroup>
{products.map((p) => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
</SelectGroup>
</SelectContent>
</Select>
)}
</div>
</header>
{product && (