feat(pitch-admin): structured form editors, bilingual fields, version preview
Some checks failed
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 32s
CI / test-python-voice (push) Successful in 32s
CI / test-bqas (push) Successful in 32s
CI / Deploy (push) Failing after 4s
Build pitch-deck / build-and-push (push) Failing after 59s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
Some checks failed
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 32s
CI / test-python-voice (push) Successful in 32s
CI / test-bqas (push) Successful in 32s
CI / Deploy (push) Failing after 4s
Build pitch-deck / build-and-push (push) Failing after 59s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
Replaces raw JSON textarea in version editor with proper form UIs: - Company: single-record form with side-by-side DE/EN tagline + mission - Team: expandable card list with bilingual role/bio, expertise tags - Financials: year-by-year table with numeric inputs - Market: TAM/SAM/SOM row table - Competitors: card list with strengths/weaknesses tag arrays - Features: card list with DE/EN names + checkbox matrix - Milestones: card list with DE/EN title/description + status dropdown - Metrics: card list with DE/EN labels - Funding: form + nested use_of_funds table - Products: card list with DE/EN capabilities + feature tag arrays - FM Scenarios: card list with color picker - FM Assumptions: row table Shared editor primitives (components/pitch-admin/editors/): BilingualField, FormField, ArrayField, RowTable, CardList "Edit as JSON" toggle preserved as escape hatch on every tab. Preview: admin clicks "Preview" on version editor → opens /pitch-preview/[versionId] in new tab showing the full pitch deck with that version's data. Admin-cookie gated (no investor auth). Yellow "PREVIEW MODE" banner at top. Also fixes the [object Object] inline table type cast in FM editor. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
44
pitch-deck/app/api/preview-data/[versionId]/route.ts
Normal file
44
pitch-deck/app/api/preview-data/[versionId]/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { getAdminFromCookie } from '@/lib/admin-auth'
|
||||
|
||||
interface Ctx { params: Promise<{ versionId: string }> }
|
||||
|
||||
export async function GET(request: NextRequest, ctx: Ctx) {
|
||||
// Admin-only: verify admin session
|
||||
const admin = await getAdminFromCookie()
|
||||
if (!admin) {
|
||||
return NextResponse.json({ error: 'Admin access required for preview' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { versionId } = await ctx.params
|
||||
|
||||
// Load version data
|
||||
const { rows } = await pool.query(
|
||||
`SELECT table_name, data FROM pitch_version_data WHERE version_id = $1`,
|
||||
[versionId],
|
||||
)
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Version not found or has no data' }, { status: 404 })
|
||||
}
|
||||
|
||||
const map: Record<string, unknown[]> = {}
|
||||
for (const row of rows) {
|
||||
map[row.table_name] = typeof row.data === 'string' ? JSON.parse(row.data) : row.data
|
||||
}
|
||||
|
||||
// Return PitchData format
|
||||
return NextResponse.json({
|
||||
company: (map.company || [])[0] || null,
|
||||
team: map.team || [],
|
||||
financials: map.financials || [],
|
||||
market: map.market || [],
|
||||
competitors: map.competitors || [],
|
||||
features: map.features || [],
|
||||
milestones: map.milestones || [],
|
||||
metrics: map.metrics || [],
|
||||
funding: (map.funding || [])[0] || null,
|
||||
products: map.products || [],
|
||||
})
|
||||
}
|
||||
@@ -132,7 +132,7 @@ export default function EditScenarioPage() {
|
||||
const isObjectArray = Array.isArray(a.value) && a.value.length > 0 && typeof a.value[0] === 'object' && a.value[0] !== null
|
||||
|
||||
if (isObjectArray) {
|
||||
const rows = isEdited ? (JSON.parse(edits[a.id]) as Record<string, unknown>[]) : (a.value as Record<string, unknown>[])
|
||||
const rows = isEdited ? (JSON.parse(edits[a.id]) as Record<string, unknown>[]) : (a.value as unknown as Record<string, unknown>[])
|
||||
const cols = Object.keys(rows[0] || {})
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,34 +3,28 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft, Lock, Save, GitFork } from 'lucide-react'
|
||||
import { ArrowLeft, Lock, Save, GitFork, Eye, Code } from 'lucide-react'
|
||||
import BilingualField from '@/components/pitch-admin/editors/BilingualField'
|
||||
import FormField from '@/components/pitch-admin/editors/FormField'
|
||||
import ArrayField from '@/components/pitch-admin/editors/ArrayField'
|
||||
import RowTable from '@/components/pitch-admin/editors/RowTable'
|
||||
import CardList from '@/components/pitch-admin/editors/CardList'
|
||||
|
||||
const TABLE_LABELS: Record<string, string> = {
|
||||
company: 'Company',
|
||||
team: 'Team',
|
||||
financials: 'Financials',
|
||||
market: 'Market',
|
||||
competitors: 'Competitors',
|
||||
features: 'Features',
|
||||
milestones: 'Milestones',
|
||||
metrics: 'Metrics',
|
||||
funding: 'Funding',
|
||||
products: 'Products',
|
||||
fm_scenarios: 'FM Scenarios',
|
||||
fm_assumptions: 'FM Assumptions',
|
||||
company: 'Company', team: 'Team', financials: 'Financials', market: 'Market',
|
||||
competitors: 'Competitors', features: 'Features', milestones: 'Milestones',
|
||||
metrics: 'Metrics', funding: 'Funding', products: 'Products',
|
||||
fm_scenarios: 'FM Scenarios', fm_assumptions: 'FM Assumptions',
|
||||
}
|
||||
|
||||
const TABLE_NAMES = Object.keys(TABLE_LABELS)
|
||||
|
||||
interface Version {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
status: 'draft' | 'committed'
|
||||
parent_id: string | null
|
||||
committed_at: string | null
|
||||
id: string; name: string; description: string | null
|
||||
status: 'draft' | 'committed'; parent_id: string | null; committed_at: string | null
|
||||
}
|
||||
|
||||
type R = Record<string, unknown>
|
||||
|
||||
export default function VersionEditorPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
@@ -38,9 +32,10 @@ export default function VersionEditorPage() {
|
||||
const [allData, setAllData] = useState<Record<string, unknown[]>>({})
|
||||
const [activeTab, setActiveTab] = useState('company')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editorValue, setEditorValue] = useState('')
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [jsonMode, setJsonMode] = useState(false)
|
||||
const [jsonText, setJsonText] = useState('')
|
||||
const [toast, setToast] = useState<string | null>(null)
|
||||
|
||||
function flashToast(msg: string) { setToast(msg); setTimeout(() => setToast(null), 3000) }
|
||||
@@ -48,53 +43,55 @@ export default function VersionEditorPage() {
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const res = await fetch(`/api/admin/versions/${id}`)
|
||||
if (res.ok) {
|
||||
const d = await res.json()
|
||||
setVersion(d.version)
|
||||
setAllData(d.data)
|
||||
}
|
||||
if (res.ok) { const d = await res.json(); setVersion(d.version); setAllData(d.data) }
|
||||
setLoading(false)
|
||||
}, [id])
|
||||
|
||||
useEffect(() => { if (id) load() }, [id, load])
|
||||
|
||||
// When tab changes, set editor value
|
||||
// Sync JSON text when switching tabs or toggling JSON mode
|
||||
useEffect(() => {
|
||||
const data = allData[activeTab]
|
||||
if (data !== undefined) {
|
||||
setEditorValue(JSON.stringify(data, null, 2))
|
||||
setDirty(false)
|
||||
}
|
||||
}, [activeTab, allData])
|
||||
if (jsonMode) setJsonText(JSON.stringify(allData[activeTab] || [], null, 2))
|
||||
}, [activeTab, jsonMode, allData])
|
||||
|
||||
function updateData(newData: unknown[]) {
|
||||
setAllData(prev => ({ ...prev, [activeTab]: newData }))
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
function updateRecord(index: number, key: string, value: unknown) {
|
||||
const arr = [...(allData[activeTab] as R[] || [])]
|
||||
arr[index] = { ...arr[index], [key]: value }
|
||||
updateData(arr)
|
||||
}
|
||||
|
||||
// For single-record tables (company, funding)
|
||||
function updateSingle(key: string, value: unknown) { updateRecord(0, key, value) }
|
||||
|
||||
async function saveTable() {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(editorValue)
|
||||
} catch {
|
||||
flashToast('Invalid JSON')
|
||||
return
|
||||
let data: unknown
|
||||
if (jsonMode) {
|
||||
try { data = JSON.parse(jsonText) } catch { flashToast('Invalid JSON'); return }
|
||||
} else {
|
||||
data = allData[activeTab]
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
const res = await fetch(`/api/admin/versions/${id}/data/${activeTab}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data: parsed }),
|
||||
body: JSON.stringify({ data }),
|
||||
})
|
||||
setSaving(false)
|
||||
if (res.ok) {
|
||||
setDirty(false)
|
||||
setAllData(prev => ({ ...prev, [activeTab]: Array.isArray(parsed) ? parsed : [parsed] }))
|
||||
if (jsonMode) setAllData(prev => ({ ...prev, [activeTab]: Array.isArray(data) ? data : [data] }))
|
||||
flashToast('Saved')
|
||||
} else {
|
||||
const d = await res.json().catch(() => ({}))
|
||||
flashToast(d.error || 'Save failed')
|
||||
}
|
||||
} else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Save failed') }
|
||||
}
|
||||
|
||||
async function commitVersion() {
|
||||
if (!confirm('Commit this version? It becomes immutable and available for investor assignment.')) return
|
||||
if (!confirm('Commit this version? It becomes immutable.')) return
|
||||
const res = await fetch(`/api/admin/versions/${id}/commit`, { method: 'POST' })
|
||||
if (res.ok) { flashToast('Committed'); load() }
|
||||
else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
|
||||
@@ -104,20 +101,328 @@ export default function VersionEditorPage() {
|
||||
const name = prompt('Name for the new draft:')
|
||||
if (!name) return
|
||||
const res = await fetch(`/api/admin/versions/${id}/fork`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const d = await res.json()
|
||||
router.push(`/pitch-admin/versions/${d.version.id}`)
|
||||
} else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
|
||||
if (res.ok) { const d = await res.json(); router.push(`/pitch-admin/versions/${d.version.id}`) }
|
||||
else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
|
||||
}
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" /></div>
|
||||
if (!version) return <div className="text-rose-400">Version not found</div>
|
||||
|
||||
const isDraft = version.status === 'draft'
|
||||
const data = allData[activeTab] || []
|
||||
const single = (data as R[])[0] || {} as R
|
||||
|
||||
function renderEditor() {
|
||||
if (jsonMode) {
|
||||
return (
|
||||
<textarea
|
||||
value={jsonText}
|
||||
onChange={e => { setJsonText(e.target.value); setDirty(true) }}
|
||||
readOnly={!isDraft}
|
||||
className="w-full bg-transparent text-white/90 font-mono text-xs p-4 focus:outline-none resize-none"
|
||||
style={{ minHeight: '400px' }}
|
||||
spellCheck={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
switch (activeTab) {
|
||||
case 'company':
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<FormField label="Company Name" value={single.name as string || ''} onChange={v => updateSingle('name', v)} />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="Legal Form" value={single.legal_form as string || ''} onChange={v => updateSingle('legal_form', v)} placeholder="GmbH" />
|
||||
<FormField label="Founding Date" value={single.founding_date as string || ''} onChange={v => updateSingle('founding_date', v)} type="date" />
|
||||
</div>
|
||||
<BilingualField label="Tagline" valueDe={single.tagline_de as string || ''} valueEn={single.tagline_en as string || ''} onChangeDe={v => updateSingle('tagline_de', v)} onChangeEn={v => updateSingle('tagline_en', v)} />
|
||||
<BilingualField label="Mission" valueDe={single.mission_de as string || ''} valueEn={single.mission_en as string || ''} onChangeDe={v => updateSingle('mission_de', v)} onChangeEn={v => updateSingle('mission_en', v)} multiline />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="Website" value={single.website as string || ''} onChange={v => updateSingle('website', v)} type="url" />
|
||||
<FormField label="HQ City" value={single.hq_city as string || ''} onChange={v => updateSingle('hq_city', v)} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'team':
|
||||
return (
|
||||
<div className="p-4">
|
||||
<CardList
|
||||
items={data as R[]}
|
||||
onChange={updateData}
|
||||
titleKey="name"
|
||||
subtitleKey="role_en"
|
||||
addLabel="Add team member"
|
||||
renderCard={(item, update) => (
|
||||
<div className="space-y-3">
|
||||
<FormField label="Name" value={item.name as string || ''} onChange={v => update('name', v)} />
|
||||
<BilingualField label="Role" valueDe={item.role_de as string || ''} valueEn={item.role_en as string || ''} onChangeDe={v => update('role_de', v)} onChangeEn={v => update('role_en', v)} />
|
||||
<BilingualField label="Bio" valueDe={item.bio_de as string || ''} valueEn={item.bio_en as string || ''} onChangeDe={v => update('bio_de', v)} onChangeEn={v => update('bio_en', v)} multiline />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="Equity %" value={item.equity_pct as number || 0} onChange={v => update('equity_pct', v)} type="number" />
|
||||
<FormField label="LinkedIn" value={item.linkedin_url as string || ''} onChange={v => update('linkedin_url', v)} type="url" />
|
||||
</div>
|
||||
<ArrayField label="Expertise" values={(item.expertise as string[]) || []} onChange={v => update('expertise', v)} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'financials':
|
||||
return (
|
||||
<div className="p-4">
|
||||
<RowTable
|
||||
rows={data as R[]}
|
||||
onChange={updateData}
|
||||
columns={[
|
||||
{ key: 'year', label: 'Year', type: 'number' },
|
||||
{ key: 'revenue_eur', label: 'Revenue (EUR)', type: 'number' },
|
||||
{ key: 'costs_eur', label: 'Costs (EUR)', type: 'number' },
|
||||
{ key: 'mrr_eur', label: 'MRR (EUR)', type: 'number' },
|
||||
{ key: 'arr_eur', label: 'ARR (EUR)', type: 'number' },
|
||||
{ key: 'customers_count', label: 'Customers', type: 'number' },
|
||||
{ key: 'employees_count', label: 'Employees', type: 'number' },
|
||||
{ key: 'burn_rate_eur', label: 'Burn (EUR)', type: 'number' },
|
||||
]}
|
||||
addLabel="Add year"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'market':
|
||||
return (
|
||||
<div className="p-4">
|
||||
<RowTable
|
||||
rows={data as R[]}
|
||||
onChange={updateData}
|
||||
columns={[
|
||||
{ key: 'market_segment', label: 'Segment' },
|
||||
{ key: 'label', label: 'Label' },
|
||||
{ key: 'value_eur', label: 'Value (EUR)', type: 'number' },
|
||||
{ key: 'growth_rate_pct', label: 'Growth %', type: 'number' },
|
||||
{ key: 'source', label: 'Source' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'competitors':
|
||||
return (
|
||||
<div className="p-4">
|
||||
<CardList
|
||||
items={data as R[]}
|
||||
onChange={updateData}
|
||||
titleKey="name"
|
||||
subtitleKey="website"
|
||||
addLabel="Add competitor"
|
||||
renderCard={(item, update) => (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="Name" value={item.name as string || ''} onChange={v => update('name', v)} />
|
||||
<FormField label="Website" value={item.website as string || ''} onChange={v => update('website', v)} type="url" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="Customers" value={item.customers_count as number || 0} onChange={v => update('customers_count', v)} type="number" />
|
||||
<FormField label="Pricing Range" value={item.pricing_range as string || ''} onChange={v => update('pricing_range', v)} />
|
||||
</div>
|
||||
<ArrayField label="Strengths" values={(item.strengths as string[]) || []} onChange={v => update('strengths', v)} />
|
||||
<ArrayField label="Weaknesses" values={(item.weaknesses as string[]) || []} onChange={v => update('weaknesses', v)} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'features':
|
||||
return (
|
||||
<div className="p-4">
|
||||
<CardList
|
||||
items={data as R[]}
|
||||
onChange={updateData}
|
||||
titleKey="feature_name_en"
|
||||
subtitleKey="category"
|
||||
addLabel="Add feature"
|
||||
renderCard={(item, update) => (
|
||||
<div className="space-y-3">
|
||||
<BilingualField label="Feature Name" valueDe={item.feature_name_de as string || ''} valueEn={item.feature_name_en as string || ''} onChangeDe={v => update('feature_name_de', v)} onChangeEn={v => update('feature_name_en', v)} />
|
||||
<FormField label="Category" value={item.category as string || ''} onChange={v => update('category', v)} />
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
<FormField label="BreakPilot" value={!!item.breakpilot} onChange={v => update('breakpilot', v)} type="checkbox" />
|
||||
<FormField label="Proliance" value={!!item.proliance} onChange={v => update('proliance', v)} type="checkbox" />
|
||||
<FormField label="DataGuard" value={!!item.dataguard} onChange={v => update('dataguard', v)} type="checkbox" />
|
||||
<FormField label="heyData" value={!!item.heydata} onChange={v => update('heydata', v)} type="checkbox" />
|
||||
<FormField label="Differentiator" value={!!item.is_differentiator} onChange={v => update('is_differentiator', v)} type="checkbox" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'milestones':
|
||||
return (
|
||||
<div className="p-4">
|
||||
<CardList
|
||||
items={data as R[]}
|
||||
onChange={updateData}
|
||||
titleKey="title_en"
|
||||
subtitleKey="milestone_date"
|
||||
addLabel="Add milestone"
|
||||
renderCard={(item, update) => (
|
||||
<div className="space-y-3">
|
||||
<BilingualField label="Title" valueDe={item.title_de as string || ''} valueEn={item.title_en as string || ''} onChangeDe={v => update('title_de', v)} onChangeEn={v => update('title_en', v)} />
|
||||
<BilingualField label="Description" valueDe={item.description_de as string || ''} valueEn={item.description_en as string || ''} onChangeDe={v => update('description_de', v)} onChangeEn={v => update('description_en', v)} multiline />
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<FormField label="Date" value={item.milestone_date as string || ''} onChange={v => update('milestone_date', v)} />
|
||||
<FormField label="Status" value={item.status as string || ''} onChange={v => update('status', v)} type="select" options={[
|
||||
{ value: 'completed', label: 'Completed' }, { value: 'in_progress', label: 'In Progress' }, { value: 'planned', label: 'Planned' },
|
||||
]} />
|
||||
<FormField label="Category" value={item.category as string || ''} onChange={v => update('category', v)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'metrics':
|
||||
return (
|
||||
<div className="p-4">
|
||||
<CardList
|
||||
items={data as R[]}
|
||||
onChange={updateData}
|
||||
titleKey="metric_name"
|
||||
subtitleKey="value"
|
||||
addLabel="Add metric"
|
||||
renderCard={(item, update) => (
|
||||
<div className="space-y-3">
|
||||
<FormField label="Metric Key" value={item.metric_name as string || ''} onChange={v => update('metric_name', v)} />
|
||||
<BilingualField label="Label" valueDe={item.label_de as string || ''} valueEn={item.label_en as string || ''} onChangeDe={v => update('label_de', v)} onChangeEn={v => update('label_en', v)} />
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<FormField label="Value" value={item.value as string || ''} onChange={v => update('value', v)} />
|
||||
<FormField label="Unit" value={item.unit as string || ''} onChange={v => update('unit', v)} />
|
||||
<FormField label="Is Live" value={!!item.is_live} onChange={v => update('is_live', v)} type="checkbox" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'funding':
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<FormField label="Round Name" value={single.round_name as string || ''} onChange={v => updateSingle('round_name', v)} />
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<FormField label="Amount (EUR)" value={single.amount_eur as number || 0} onChange={v => updateSingle('amount_eur', v)} type="number" />
|
||||
<FormField label="Instrument" value={single.instrument as string || ''} onChange={v => updateSingle('instrument', v)} />
|
||||
<FormField label="Target Date" value={single.target_date as string || ''} onChange={v => updateSingle('target_date', v)} type="date" />
|
||||
</div>
|
||||
<FormField label="Status" value={single.status as string || ''} onChange={v => updateSingle('status', v)} type="select" options={[
|
||||
{ value: 'planned', label: 'Planned' }, { value: 'in_progress', label: 'In Progress' }, { value: 'completed', label: 'Completed' },
|
||||
]} />
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">Use of Funds</label>
|
||||
<RowTable
|
||||
rows={(single.use_of_funds as R[]) || []}
|
||||
onChange={v => updateSingle('use_of_funds', v)}
|
||||
columns={[
|
||||
{ key: 'category', label: 'Category' },
|
||||
{ key: 'percentage', label: '%', type: 'number' },
|
||||
{ key: 'label_de', label: 'Label DE' },
|
||||
{ key: 'label_en', label: 'Label EN' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'products':
|
||||
return (
|
||||
<div className="p-4">
|
||||
<CardList
|
||||
items={data as R[]}
|
||||
onChange={updateData}
|
||||
titleKey="name"
|
||||
subtitleKey="hardware"
|
||||
addLabel="Add product"
|
||||
renderCard={(item, update) => (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="Name" value={item.name as string || ''} onChange={v => update('name', v)} />
|
||||
<FormField label="Hardware" value={item.hardware as string || ''} onChange={v => update('hardware', v)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<FormField label="HW Cost (EUR)" value={item.hardware_cost_eur as number || 0} onChange={v => update('hardware_cost_eur', v)} type="number" />
|
||||
<FormField label="Monthly Price (EUR)" value={item.monthly_price_eur as number || 0} onChange={v => update('monthly_price_eur', v)} type="number" />
|
||||
<FormField label="Operating Cost (EUR)" value={item.operating_cost_eur as number || 0} onChange={v => update('operating_cost_eur', v)} type="number" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="LLM Model" value={item.llm_model as string || ''} onChange={v => update('llm_model', v)} />
|
||||
<FormField label="LLM Size" value={item.llm_size as string || ''} onChange={v => update('llm_size', v)} />
|
||||
</div>
|
||||
<BilingualField label="LLM Capability" valueDe={item.llm_capability_de as string || ''} valueEn={item.llm_capability_en as string || ''} onChangeDe={v => update('llm_capability_de', v)} onChangeEn={v => update('llm_capability_en', v)} multiline />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<ArrayField label="Features (DE)" values={(item.features_de as string[]) || []} onChange={v => update('features_de', v)} />
|
||||
<ArrayField label="Features (EN)" values={(item.features_en as string[]) || []} onChange={v => update('features_en', v)} />
|
||||
</div>
|
||||
<FormField label="Popular" value={!!item.is_popular} onChange={v => update('is_popular', v)} type="checkbox" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'fm_scenarios':
|
||||
return (
|
||||
<div className="p-4">
|
||||
<CardList
|
||||
items={data as R[]}
|
||||
onChange={updateData}
|
||||
titleKey="name"
|
||||
subtitleKey="description"
|
||||
addLabel="Add scenario"
|
||||
renderCard={(item, update) => (
|
||||
<div className="space-y-3">
|
||||
<FormField label="Name" value={item.name as string || ''} onChange={v => update('name', v)} />
|
||||
<FormField label="Description" value={item.description as string || ''} onChange={v => update('description', v)} />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="Color" value={item.color as string || '#6366f1'} onChange={v => update('color', v)} type="color" />
|
||||
<FormField label="Default" value={!!item.is_default} onChange={v => update('is_default', v)} type="checkbox" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'fm_assumptions':
|
||||
// Reuse the inline table approach from the FM editor (already works well for this)
|
||||
return (
|
||||
<div className="p-4">
|
||||
<RowTable
|
||||
rows={data as R[]}
|
||||
onChange={updateData}
|
||||
columns={[
|
||||
{ key: 'key', label: 'Key' },
|
||||
{ key: 'label_de', label: 'Label DE' },
|
||||
{ key: 'label_en', label: 'Label EN' },
|
||||
{ key: 'category', label: 'Category' },
|
||||
{ key: 'unit', label: 'Unit' },
|
||||
]}
|
||||
addLabel="Add assumption"
|
||||
/>
|
||||
<p className="text-[10px] text-white/30 mt-2">Note: values, min/max/step are best edited via "Edit as JSON" mode for complex types.</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return <div className="p-4 text-white/40">No editor for this table</div>
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -137,37 +442,35 @@ export default function VersionEditorPage() {
|
||||
{version.description && <p className="text-sm text-white/50">{version.description}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/pitch-preview/${id}`}
|
||||
target="_blank"
|
||||
className="bg-white/[0.06] hover:bg-white/[0.1] text-white text-sm px-4 py-2 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<Eye className="w-4 h-4" /> Preview
|
||||
</Link>
|
||||
{isDraft && (
|
||||
<button
|
||||
onClick={commitVersion}
|
||||
className="bg-green-500/15 hover:bg-green-500/25 text-green-300 text-sm px-4 py-2 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<button onClick={commitVersion} className="bg-green-500/15 hover:bg-green-500/25 text-green-300 text-sm px-4 py-2 rounded-lg flex items-center gap-2">
|
||||
<Lock className="w-4 h-4" /> Commit
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={forkVersion}
|
||||
className="bg-indigo-500/15 hover:bg-indigo-500/25 text-indigo-300 text-sm px-4 py-2 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<button onClick={forkVersion} className="bg-indigo-500/15 hover:bg-indigo-500/25 text-indigo-300 text-sm px-4 py-2 rounded-lg flex items-center gap-2">
|
||||
<GitFork className="w-4 h-4" /> Fork
|
||||
</button>
|
||||
{version.parent_id && (
|
||||
<Link
|
||||
href={`/pitch-admin/versions/${id}/diff/${version.parent_id}`}
|
||||
className="bg-white/[0.06] hover:bg-white/[0.1] text-white text-sm px-4 py-2 rounded-lg"
|
||||
>
|
||||
Diff with parent
|
||||
<Link href={`/pitch-admin/versions/${id}/diff/${version.parent_id}`} className="bg-white/[0.06] hover:bg-white/[0.1] text-white text-sm px-4 py-2 rounded-lg">
|
||||
Diff
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab navigation */}
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 overflow-x-auto pb-1">
|
||||
{TABLE_NAMES.map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => { if (dirty && !confirm('Discard unsaved changes?')) return; setActiveTab(t) }}
|
||||
onClick={() => { if (dirty && !confirm('Discard unsaved changes?')) return; setActiveTab(t); setDirty(false); setJsonMode(false) }}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs whitespace-nowrap transition-colors ${
|
||||
activeTab === t
|
||||
? 'bg-indigo-500/20 text-indigo-300 border border-indigo-500/30'
|
||||
@@ -179,38 +482,37 @@ export default function VersionEditorPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* JSON editor */}
|
||||
{/* Editor */}
|
||||
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-white/[0.06]">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-semibold text-white">{TABLE_LABELS[activeTab]}</span>
|
||||
{dirty && <span className="text-[9px] px-2 py-0.5 rounded bg-amber-500/20 text-amber-300">Unsaved</span>}
|
||||
</div>
|
||||
{isDraft && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={saveTable}
|
||||
disabled={saving || !dirty}
|
||||
className="bg-indigo-500 hover:bg-indigo-600 text-white text-sm font-medium px-4 py-1.5 rounded-lg flex items-center gap-2 disabled:opacity-30"
|
||||
onClick={() => setJsonMode(!jsonMode)}
|
||||
className={`text-[10px] px-2 py-1 rounded flex items-center gap-1 transition-colors ${
|
||||
jsonMode ? 'bg-indigo-500/20 text-indigo-300' : 'bg-white/[0.04] text-white/40 hover:text-white/60'
|
||||
}`}
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" /> {saving ? 'Saving…' : 'Save'}
|
||||
<Code className="w-3 h-3" /> {jsonMode ? 'Form' : 'JSON'}
|
||||
</button>
|
||||
)}
|
||||
{isDraft && (
|
||||
<button
|
||||
onClick={saveTable}
|
||||
disabled={saving || !dirty}
|
||||
className="bg-indigo-500 hover:bg-indigo-600 text-white text-sm font-medium px-4 py-1.5 rounded-lg flex items-center gap-2 disabled:opacity-30"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" /> {saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={editorValue}
|
||||
onChange={e => { setEditorValue(e.target.value); setDirty(true) }}
|
||||
readOnly={!isDraft}
|
||||
className="w-full bg-transparent text-white/90 font-mono text-xs p-4 focus:outline-none resize-none"
|
||||
style={{ minHeight: '400px' }}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{renderEditor()}
|
||||
</div>
|
||||
|
||||
{!isDraft && (
|
||||
<p className="text-xs text-white/30 text-center">
|
||||
This version is committed and read-only. Fork it to make changes.
|
||||
</p>
|
||||
)}
|
||||
{!isDraft && <p className="text-xs text-white/30 text-center">Committed — read-only. Fork to edit.</p>}
|
||||
|
||||
{toast && (
|
||||
<div className="fixed bottom-6 right-6 bg-indigo-500/20 border border-indigo-500/40 text-indigo-200 text-sm px-4 py-2 rounded-lg backdrop-blur-sm z-50">
|
||||
|
||||
72
pitch-deck/app/pitch-preview/[versionId]/page.tsx
Normal file
72
pitch-deck/app/pitch-preview/[versionId]/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Language, PitchData } from '@/lib/types'
|
||||
import PitchDeck from '@/components/PitchDeck'
|
||||
|
||||
export default function PreviewPage() {
|
||||
const { versionId } = useParams<{ versionId: string }>()
|
||||
const [data, setData] = useState<PitchData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lang, setLang] = useState<Language>('de')
|
||||
|
||||
const toggleLanguage = useCallback(() => {
|
||||
setLang(prev => prev === 'de' ? 'en' : 'de')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!versionId) return
|
||||
setLoading(true)
|
||||
fetch(`/api/preview-data/${versionId}`)
|
||||
.then(async r => {
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'Failed to load')
|
||||
return r.json()
|
||||
})
|
||||
.then(setData)
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [versionId])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-[#0a0a1a]">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-white/40 text-sm">Loading preview...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-[#0a0a1a]">
|
||||
<div className="text-center max-w-md px-6">
|
||||
<p className="text-rose-400 mb-2">Preview Error</p>
|
||||
<p className="text-white/40 text-sm">{error || 'No data found for this version'}</p>
|
||||
<p className="text-white/30 text-xs mt-4">Make sure you are logged in as an admin.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render PitchDeck with no investor (no watermark, no audit) — admin preview only
|
||||
// The banner at the top indicates this is a preview
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Preview banner */}
|
||||
<div className="fixed top-0 left-0 right-0 z-[100] bg-amber-500/90 text-black text-center py-1.5 text-xs font-semibold">
|
||||
PREVIEW MODE — This is how investors will see this version
|
||||
</div>
|
||||
<PitchDeck
|
||||
lang={lang}
|
||||
onToggleLanguage={toggleLanguage}
|
||||
investor={null}
|
||||
onLogout={() => {}}
|
||||
previewData={data}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -47,10 +47,14 @@ interface PitchDeckProps {
|
||||
onToggleLanguage: () => void
|
||||
investor: Investor | null
|
||||
onLogout: () => void
|
||||
previewData?: PitchData | null
|
||||
}
|
||||
|
||||
export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout }: PitchDeckProps) {
|
||||
const { data, loading, error } = usePitchData()
|
||||
export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout, previewData }: PitchDeckProps) {
|
||||
const fetched = usePitchData()
|
||||
const data = previewData || fetched.data
|
||||
const loading = previewData ? false : fetched.loading
|
||||
const error = previewData ? null : fetched.error
|
||||
const nav = useSlideNavigation()
|
||||
const [fabOpen, setFabOpen] = useState(false)
|
||||
|
||||
|
||||
56
pitch-deck/components/pitch-admin/editors/ArrayField.tsx
Normal file
56
pitch-deck/components/pitch-admin/editors/ArrayField.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { X, Plus } from 'lucide-react'
|
||||
|
||||
interface ArrayFieldProps {
|
||||
label: string
|
||||
values: string[]
|
||||
onChange: (v: string[]) => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export default function ArrayField({ label, values, onChange, placeholder }: ArrayFieldProps) {
|
||||
const [input, setInput] = useState('')
|
||||
|
||||
function add() {
|
||||
const v = input.trim()
|
||||
if (v && !values.includes(v)) {
|
||||
onChange([...values, v])
|
||||
setInput('')
|
||||
}
|
||||
}
|
||||
|
||||
function remove(idx: number) {
|
||||
onChange(values.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">{label}</label>
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{values.map((v, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 bg-indigo-500/15 text-indigo-300 text-xs px-2 py-1 rounded-lg border border-indigo-500/20">
|
||||
{v}
|
||||
<button onClick={() => remove(i)} className="hover:text-rose-300">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); add() } }}
|
||||
placeholder={placeholder || 'Type and press Enter'}
|
||||
className="flex-1 bg-black/30 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40 placeholder:text-white/20"
|
||||
/>
|
||||
<button onClick={add} className="bg-white/[0.06] hover:bg-white/[0.1] text-white/60 p-1.5 rounded-lg">
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
69
pitch-deck/components/pitch-admin/editors/BilingualField.tsx
Normal file
69
pitch-deck/components/pitch-admin/editors/BilingualField.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
interface BilingualFieldProps {
|
||||
label: string
|
||||
valueDe: string
|
||||
valueEn: string
|
||||
onChangeDe: (v: string) => void
|
||||
onChangeEn: (v: string) => void
|
||||
multiline?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export default function BilingualField({
|
||||
label, valueDe, valueEn, onChangeDe, onChangeEn, multiline, placeholder,
|
||||
}: BilingualFieldProps) {
|
||||
const inputClass = 'w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40 placeholder:text-white/20'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">{label}</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<span className="text-[10px] text-white/40 font-semibold">DE</span>
|
||||
</div>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
value={valueDe || ''}
|
||||
onChange={e => onChangeDe(e.target.value)}
|
||||
rows={3}
|
||||
placeholder={placeholder}
|
||||
className={`${inputClass} resize-none`}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={valueDe || ''}
|
||||
onChange={e => onChangeDe(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<span className="text-[10px] text-white/40 font-semibold">EN</span>
|
||||
</div>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
value={valueEn || ''}
|
||||
onChange={e => onChangeEn(e.target.value)}
|
||||
rows={3}
|
||||
placeholder={placeholder}
|
||||
className={`${inputClass} resize-none`}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={valueEn || ''}
|
||||
onChange={e => onChangeEn(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
115
pitch-deck/components/pitch-admin/editors/CardList.tsx
Normal file
115
pitch-deck/components/pitch-admin/editors/CardList.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, ChevronRight, Plus, Trash2, GripVertical } from 'lucide-react'
|
||||
|
||||
interface CardListProps {
|
||||
items: Record<string, unknown>[]
|
||||
onChange: (items: Record<string, unknown>[]) => void
|
||||
titleKey: string
|
||||
subtitleKey?: string
|
||||
renderCard: (item: Record<string, unknown>, update: (key: string, value: unknown) => void) => React.ReactNode
|
||||
newItemTemplate?: Record<string, unknown>
|
||||
addLabel?: string
|
||||
}
|
||||
|
||||
export default function CardList({
|
||||
items, onChange, titleKey, subtitleKey, renderCard, newItemTemplate, addLabel,
|
||||
}: CardListProps) {
|
||||
const [expandedIdx, setExpandedIdx] = useState<number | null>(null)
|
||||
|
||||
function updateItem(idx: number, key: string, value: unknown) {
|
||||
onChange(items.map((item, i) => i === idx ? { ...item, [key]: value } : item))
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
const newItem = newItemTemplate || (() => {
|
||||
const template: Record<string, unknown> = {}
|
||||
if (items.length > 0) {
|
||||
Object.keys(items[0]).forEach(k => {
|
||||
const sample = items[0][k]
|
||||
template[k] = Array.isArray(sample) ? [] : typeof sample === 'number' ? 0 : typeof sample === 'boolean' ? false : ''
|
||||
})
|
||||
}
|
||||
if ('sort_order' in template) template.sort_order = items.length
|
||||
return template
|
||||
})()
|
||||
onChange([...items, newItem])
|
||||
setExpandedIdx(items.length)
|
||||
}
|
||||
|
||||
function removeItem(idx: number) {
|
||||
if (!confirm('Remove this item?')) return
|
||||
onChange(items.filter((_, i) => i !== idx))
|
||||
if (expandedIdx === idx) setExpandedIdx(null)
|
||||
}
|
||||
|
||||
function moveUp(idx: number) {
|
||||
if (idx === 0) return
|
||||
const copy = [...items]
|
||||
;[copy[idx - 1], copy[idx]] = [copy[idx], copy[idx - 1]]
|
||||
onChange(copy)
|
||||
setExpandedIdx(idx - 1)
|
||||
}
|
||||
|
||||
function moveDown(idx: number) {
|
||||
if (idx >= items.length - 1) return
|
||||
const copy = [...items]
|
||||
;[copy[idx], copy[idx + 1]] = [copy[idx + 1], copy[idx]]
|
||||
onChange(copy)
|
||||
setExpandedIdx(idx + 1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((item, idx) => {
|
||||
const isExpanded = expandedIdx === idx
|
||||
const title = String(item[titleKey] || `Item ${idx + 1}`)
|
||||
const subtitle = subtitleKey ? String(item[subtitleKey] || '') : ''
|
||||
|
||||
return (
|
||||
<div key={idx} className="border border-white/[0.06] rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => setExpandedIdx(isExpanded ? null : idx)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-white/[0.02] text-left"
|
||||
>
|
||||
<div className="flex items-center gap-1 text-white/30">
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); moveUp(idx) }}
|
||||
className="hover:text-white/60 p-0.5"
|
||||
title="Move up"
|
||||
>
|
||||
<GripVertical className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded ? <ChevronDown className="w-4 h-4 text-white/40" /> : <ChevronRight className="w-4 h-4 text-white/40" />}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm text-white/90 font-medium truncate block">{title}</span>
|
||||
{subtitle && <span className="text-xs text-white/40 truncate block">{subtitle}</span>}
|
||||
</div>
|
||||
<span className="text-[9px] text-white/30 font-mono">#{idx + 1}</span>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); removeItem(idx) }}
|
||||
className="text-white/30 hover:text-rose-400 p-1"
|
||||
title="Remove"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 pt-1 border-t border-white/[0.04] space-y-4">
|
||||
{renderCard(item, (key, value) => updateItem(idx, key, value))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
onClick={addItem}
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 text-xs text-white/50 hover:text-white border border-dashed border-white/[0.1] hover:border-white/[0.2] rounded-xl transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> {addLabel || 'Add item'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
69
pitch-deck/components/pitch-admin/editors/FormField.tsx
Normal file
69
pitch-deck/components/pitch-admin/editors/FormField.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
interface FormFieldProps {
|
||||
label: string
|
||||
value: string | number | boolean
|
||||
onChange: (v: string | number | boolean) => void
|
||||
type?: 'text' | 'number' | 'date' | 'url' | 'checkbox' | 'select' | 'color'
|
||||
placeholder?: string
|
||||
options?: { value: string; label: string }[]
|
||||
hint?: string
|
||||
}
|
||||
|
||||
export default function FormField({
|
||||
label, value, onChange, type = 'text', placeholder, options, hint,
|
||||
}: FormFieldProps) {
|
||||
const inputClass = 'w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40 placeholder:text-white/20'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">{label}</label>
|
||||
|
||||
{type === 'checkbox' ? (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!value}
|
||||
onChange={e => onChange(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-white/20 bg-black/30 text-indigo-500 focus:ring-indigo-500/40"
|
||||
/>
|
||||
<span className="text-sm text-white/70">{placeholder || label}</span>
|
||||
</label>
|
||||
) : type === 'select' && options ? (
|
||||
<select
|
||||
value={String(value)}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
{options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
) : type === 'color' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={String(value) || '#6366f1'}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className="w-10 h-10 rounded-lg border border-white/10 cursor-pointer bg-transparent"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={String(value)}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className={`${inputClass} flex-1`}
|
||||
placeholder="#6366f1"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
type={type}
|
||||
value={value as string | number}
|
||||
onChange={e => onChange(type === 'number' ? Number(e.target.value) || 0 : e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hint && <p className="text-[10px] text-white/30 mt-1">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
92
pitch-deck/components/pitch-admin/editors/RowTable.tsx
Normal file
92
pitch-deck/components/pitch-admin/editors/RowTable.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client'
|
||||
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
|
||||
interface RowTableProps {
|
||||
rows: Record<string, unknown>[]
|
||||
onChange: (rows: Record<string, unknown>[]) => void
|
||||
columns?: { key: string; label: string; type?: 'text' | 'number' }[]
|
||||
addLabel?: string
|
||||
}
|
||||
|
||||
export default function RowTable({ rows, onChange, columns, addLabel }: RowTableProps) {
|
||||
// Auto-detect columns from first row if not provided
|
||||
const cols = columns || (rows.length > 0
|
||||
? Object.keys(rows[0]).filter(k => k !== 'id' && k !== 'sort_order').map(k => ({
|
||||
key: k,
|
||||
label: k.replace(/_/g, ' '),
|
||||
type: (typeof rows[0][k] === 'number' ? 'number' : 'text') as 'text' | 'number',
|
||||
}))
|
||||
: [])
|
||||
|
||||
function updateCell(rowIdx: number, key: string, value: string) {
|
||||
const col = cols.find(c => c.key === key)
|
||||
const parsedValue = col?.type === 'number' ? (Number(value) || 0) : value
|
||||
onChange(rows.map((r, i) => i === rowIdx ? { ...r, [key]: parsedValue } : r))
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
const newRow: Record<string, unknown> = {}
|
||||
cols.forEach(c => { newRow[c.key] = c.type === 'number' ? 0 : '' })
|
||||
// Carry over id-like fields
|
||||
if (rows.length > 0 && 'id' in rows[0]) {
|
||||
newRow.id = (rows.length + 1)
|
||||
}
|
||||
if (rows.length > 0 && 'sort_order' in rows[0]) {
|
||||
newRow.sort_order = rows.length
|
||||
}
|
||||
onChange([...rows, newRow])
|
||||
}
|
||||
|
||||
function removeRow(idx: number) {
|
||||
onChange(rows.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
if (cols.length === 0) return <div className="text-white/40 text-sm">No columns detected</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.08]">
|
||||
{cols.map(c => (
|
||||
<th key={c.key} className="text-left py-2 px-2 text-[10px] text-white/40 font-medium uppercase tracking-wider whitespace-nowrap">
|
||||
{c.label}
|
||||
</th>
|
||||
))}
|
||||
<th className="w-8" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, ri) => (
|
||||
<tr key={ri} className="border-b border-white/[0.04] hover:bg-white/[0.02]">
|
||||
{cols.map(c => (
|
||||
<td key={c.key} className="py-1 px-2">
|
||||
<input
|
||||
type={c.type || 'text'}
|
||||
value={(row[c.key] as string | number) ?? ''}
|
||||
onChange={e => updateCell(ri, c.key, e.target.value)}
|
||||
className="w-full bg-transparent border-b border-transparent hover:border-white/10 focus:border-indigo-500/50 text-white font-mono text-xs py-1 focus:outline-none min-w-[60px]"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className="py-1 px-1">
|
||||
<button onClick={() => removeRow(ri)} className="text-white/30 hover:text-rose-400 p-1" title="Remove">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button
|
||||
onClick={addRow}
|
||||
className="mt-2 text-xs text-white/50 hover:text-white flex items-center gap-1 px-2 py-1 rounded hover:bg-white/[0.04]"
|
||||
>
|
||||
<Plus className="w-3 h-3" /> {addLabel || 'Add row'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -16,7 +16,7 @@ const PUBLIC_PATHS = [
|
||||
]
|
||||
|
||||
// Paths gated on the admin session cookie
|
||||
const ADMIN_GATED_PREFIXES = ['/pitch-admin', '/api/admin']
|
||||
const ADMIN_GATED_PREFIXES = ['/pitch-admin', '/api/admin', '/pitch-preview', '/api/preview-data']
|
||||
|
||||
function isPublicPath(pathname: string): boolean {
|
||||
return PUBLIC_PATHS.some(p => pathname === p || pathname.startsWith(p + '/'))
|
||||
|
||||
Reference in New Issue
Block a user