feat(dataroom): bilingual descriptions, drag-drop multi-file upload, edit existing upload descriptions
Build pitch-deck / build-push-deploy (push) Successful in 1m47s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 39s
CI / test-python-voice (push) Successful in 32s
CI / test-bqas (push) Successful in 32s

- lib/translate.ts: LiteLLM DE<>EN translation utility
- Migration 006: description_de/description_en on both dataroom tables
- Admin + investor upload APIs: accept description+lang, auto-translate the other language on save
- PATCH /api/admin/dataroom/documents/[id]: description path in addition to display_name path
- PATCH /api/dataroom/uploads/[id]: investor can edit their own upload descriptions
- PATCH /api/admin/dataroom/investors/[id]/uploads: admin can edit investor upload descriptions
- All GET queries updated to return description fields
- Admin dataroom: drop zone replaces upload button, multi-file, inline description editor per doc and per investor upload
- Investor dataroom: drop zone, multi-file, description+lang textarea before upload, inline description editing on existing uploads

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-05-01 21:00:36 +02:00
parent 370143b643
commit f130c45ca8
10 changed files with 521 additions and 198 deletions
@@ -3,6 +3,7 @@ import pool from '@/lib/db'
import { requireAdmin, getAdminFromCookie } from '@/lib/admin-auth'
import { adminDocDir, removeDir } from '@/lib/dataroom-storage'
import { logAudit } from '@/lib/auth'
import { translateText } from '@/lib/translate'
interface Ctx { params: Promise<{ id: string }> }
@@ -11,8 +12,25 @@ export async function PATCH(request: NextRequest, ctx: Ctx) {
if (guard.kind === 'response') return guard.response
const { id } = await ctx.params
const { display_name } = await request.json()
const body = await request.json()
if ('description' in body) {
const { description, description_lang } = body as { description: string | null; description_lang: 'de' | 'en' }
const lang = description_lang || 'en'
const translated = description ? await translateText(description, lang) : null
const desc_de = lang === 'de' ? (description || null) : translated
const desc_en = lang === 'en' ? (description || null) : translated
const { rows } = await pool.query(
`UPDATE dataroom_documents SET description_de = $1, description_en = $2, updated_at = NOW()
WHERE id = $3 RETURNING *`,
[desc_de, desc_en, id],
)
if (rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 })
return NextResponse.json({ document: rows[0] })
}
const { display_name } = body
const { rows } = await pool.query(
`UPDATE dataroom_documents SET display_name = $1, updated_at = NOW()
WHERE id = $2 RETURNING *`,
@@ -3,6 +3,7 @@ import pool from '@/lib/db'
import { requireAdmin, getAdminFromCookie } from '@/lib/admin-auth'
import { adminDocDir, saveFile, safeName } from '@/lib/dataroom-storage'
import { logAudit } from '@/lib/auth'
import { translateText } from '@/lib/translate'
import { randomUUID } from 'crypto'
export const dynamic = 'force-dynamic'
@@ -12,7 +13,8 @@ export async function GET(request: NextRequest) {
if (guard.kind === 'response') return guard.response
const { rows } = await pool.query(
`SELECT d.id, d.filename, d.display_name, d.mime_type, d.file_size, d.uploaded_by, d.created_at,
`SELECT d.id, d.filename, d.display_name, d.description_de, d.description_en,
d.mime_type, d.file_size, d.uploaded_by, d.created_at,
COUNT(r.id)::int AS release_count
FROM dataroom_documents d
LEFT JOIN dataroom_releases r ON r.document_id = d.id
@@ -28,25 +30,40 @@ export async function POST(request: NextRequest) {
const admin = await getAdminFromCookie()
const formData = await request.formData()
const file = formData.get('file') as File | null
const displayName = (formData.get('display_name') as string | null) || null
if (!file || file.size === 0) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
const files = formData.getAll('file') as File[]
const validFiles = files.filter(f => f && f.size > 0)
if (validFiles.length === 0) return NextResponse.json({ error: 'No files provided' }, { status: 400 })
const description = (formData.get('description') as string | null) || null
const descLang = (formData.get('description_lang') as 'de' | 'en' | null) || 'en'
let desc_de: string | null = null
let desc_en: string | null = null
if (description) {
const translated = await translateText(description, descLang)
desc_de = descLang === 'de' ? description : (translated || null)
desc_en = descLang === 'en' ? description : (translated || null)
}
const inserted = []
for (const file of validFiles) {
const documentId = randomUUID()
const filename = safeName(file.name)
const buffer = Buffer.from(await file.arrayBuffer())
const filePath = await saveFile(adminDocDir(documentId), filename, buffer)
const { rows } = await pool.query(
`INSERT INTO dataroom_documents (id, filename, file_path, display_name, mime_type, file_size, uploaded_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`INSERT INTO dataroom_documents
(id, filename, file_path, display_name, description_de, description_en, mime_type, file_size, uploaded_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
RETURNING *`,
[documentId, filename, filePath, displayName || file.name, file.type || 'application/octet-stream', file.size, admin?.email ?? 'admin'],
[documentId, filename, filePath, file.name, desc_de, desc_en,
file.type || 'application/octet-stream', file.size, admin?.email ?? 'admin'],
)
await logAudit(null, 'dataroom_document_uploaded', { document_id: documentId, filename, file_size: file.size }, request, undefined, undefined, admin?.id)
return NextResponse.json({ document: rows[0] }, { status: 201 })
inserted.push(rows[0])
}
return NextResponse.json({ documents: inserted }, { status: 201 })
}
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { requireAdmin } from '@/lib/admin-auth'
import { streamFile } from '@/lib/dataroom-storage'
import { translateText } from '@/lib/translate'
import path from 'path'
interface Ctx { params: Promise<{ id: string }> }
@@ -31,7 +32,7 @@ export async function GET(request: NextRequest, ctx: Ctx) {
}
const { rows } = await pool.query(
`SELECT id, filename, display_name, mime_type, file_size, created_at
`SELECT id, filename, display_name, description_de, description_en, mime_type, file_size, created_at
FROM dataroom_investor_uploads
WHERE investor_id = $1
ORDER BY created_at DESC`,
@@ -39,3 +40,25 @@ export async function GET(request: NextRequest, ctx: Ctx) {
)
return NextResponse.json({ uploads: rows })
}
export async function PATCH(request: NextRequest, ctx: Ctx) {
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
const { id: investorId } = await ctx.params
const { upload_id, description, description_lang } = await request.json()
const lang: 'de' | 'en' = description_lang || 'en'
const translated = description ? await translateText(description, lang) : null
const desc_de = lang === 'de' ? (description || null) : translated
const desc_en = lang === 'en' ? (description || null) : translated
const { rows } = await pool.query(
`UPDATE dataroom_investor_uploads SET description_de = $1, description_en = $2
WHERE id = $3 AND investor_id = $4
RETURNING id, filename, display_name, description_de, description_en, mime_type, file_size, created_at`,
[desc_de, desc_en, upload_id, investorId],
)
if (rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 })
return NextResponse.json({ upload: rows[0] })
}
@@ -155,6 +155,11 @@ export async function POST(request: NextRequest) {
`CREATE INDEX IF NOT EXISTS idx_dataroom_releases_investor ON dataroom_releases(investor_id)`,
`CREATE INDEX IF NOT EXISTS idx_dataroom_releases_document ON dataroom_releases(document_id)`,
`CREATE INDEX IF NOT EXISTS idx_dataroom_uploads_investor ON dataroom_investor_uploads(investor_id)`,
// 006 — dataroom bilingual descriptions
`ALTER TABLE dataroom_documents ADD COLUMN IF NOT EXISTS description_de TEXT`,
`ALTER TABLE dataroom_documents ADD COLUMN IF NOT EXISTS description_en TEXT`,
`ALTER TABLE dataroom_investor_uploads ADD COLUMN IF NOT EXISTS description_de TEXT`,
`ALTER TABLE dataroom_investor_uploads ADD COLUMN IF NOT EXISTS description_en TEXT`,
]
for (const sql of statements) {
@@ -4,13 +4,14 @@ import { getSessionFromCookie } from '@/lib/auth'
export const dynamic = 'force-dynamic'
export async function GET(request: NextRequest) {
export async function GET(_request: NextRequest) {
const session = await getSessionFromCookie()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const investorId = session.sub
const { rows } = await pool.query(
`SELECT d.id, d.filename, d.display_name, d.mime_type, d.file_size, r.released_at
`SELECT d.id, d.filename, d.display_name, d.description_de, d.description_en,
d.mime_type, d.file_size, r.released_at
FROM dataroom_releases r
JOIN dataroom_documents d ON d.id = r.document_id
WHERE r.investor_id = $1
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { getSessionFromCookie } from '@/lib/auth'
import { translateText } from '@/lib/translate'
interface Ctx { params: Promise<{ id: string }> }
export async function PATCH(request: NextRequest, ctx: Ctx) {
const session = await getSessionFromCookie()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { id } = await ctx.params
const { description, description_lang } = await request.json()
const lang: 'de' | 'en' = description_lang || 'en'
const translated = description ? await translateText(description, lang) : null
const desc_de = lang === 'de' ? (description || null) : translated
const desc_en = lang === 'en' ? (description || null) : translated
const { rows } = await pool.query(
`UPDATE dataroom_investor_uploads SET description_de = $1, description_en = $2
WHERE id = $3 AND investor_id = $4
RETURNING id, filename, display_name, description_de, description_en, mime_type, file_size, created_at`,
[desc_de, desc_en, id, session.sub],
)
if (rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 })
return NextResponse.json({ upload: rows[0] })
}
+30 -11
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { investorUploadDir, saveFile, safeName } from '@/lib/dataroom-storage'
import { logAudit, getSessionFromCookie } from '@/lib/auth'
import { translateText } from '@/lib/translate'
import { randomUUID } from 'crypto'
export const dynamic = 'force-dynamic'
@@ -14,7 +15,7 @@ export async function GET(_request: NextRequest) {
const investorId = session.sub
const { rows } = await pool.query(
`SELECT id, filename, display_name, mime_type, file_size, created_at
`SELECT id, filename, display_name, description_de, description_en, mime_type, file_size, created_at
FROM dataroom_investor_uploads
WHERE investor_id = $1
ORDER BY created_at DESC`,
@@ -30,24 +31,42 @@ export async function POST(request: NextRequest) {
const sessionId = session.sessionId
const formData = await request.formData()
const file = formData.get('file') as File | null
const displayName = (formData.get('display_name') as string | null) || null
const files = formData.getAll('file') as File[]
const validFiles = files.filter(f => f && f.size > 0)
if (validFiles.length === 0) return NextResponse.json({ error: 'No files provided' }, { status: 400 })
if (!file || file.size === 0) return NextResponse.json({ error: 'No file provided' }, { status: 400 })
if (file.size > MAX_BYTES) return NextResponse.json({ error: `File exceeds ${process.env.DATAROOM_MAX_UPLOAD_MB || 50}MB limit` }, { status: 413 })
const oversized = validFiles.find(f => f.size > MAX_BYTES)
if (oversized) return NextResponse.json({ error: `File "${oversized.name}" exceeds ${process.env.DATAROOM_MAX_UPLOAD_MB || 50}MB limit` }, { status: 413 })
const description = (formData.get('description') as string | null) || null
const descLang = (formData.get('description_lang') as 'de' | 'en' | null) || 'en'
let desc_de: string | null = null
let desc_en: string | null = null
if (description) {
const translated = await translateText(description, descLang)
desc_de = descLang === 'de' ? description : (translated || null)
desc_en = descLang === 'en' ? description : (translated || null)
}
const inserted = []
for (const file of validFiles) {
const uploadId = randomUUID()
const filename = safeName(file.name)
const buffer = Buffer.from(await file.arrayBuffer())
const filePath = await saveFile(investorUploadDir(investorId, uploadId), filename, buffer)
const { rows } = await pool.query(
`INSERT INTO dataroom_investor_uploads (id, investor_id, filename, file_path, display_name, mime_type, file_size)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, filename, display_name, mime_type, file_size, created_at`,
[uploadId, investorId, filename, filePath, displayName || file.name, file.type || 'application/octet-stream', file.size],
`INSERT INTO dataroom_investor_uploads
(id, investor_id, filename, file_path, display_name, description_de, description_en, mime_type, file_size)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
RETURNING id, filename, display_name, description_de, description_en, mime_type, file_size, created_at`,
[uploadId, investorId, filename, filePath, file.name, desc_de, desc_en,
file.type || 'application/octet-stream', file.size],
)
await logAudit(investorId, 'dataroom_investor_uploaded', { upload_id: uploadId, filename, file_size: file.size }, request, undefined, sessionId)
inserted.push(rows[0])
}
await logAudit(investorId, 'dataroom_investor_uploaded', { upload_id: uploadId, filename, file_size: file.size }, request, undefined, sessionId ?? undefined)
return NextResponse.json({ upload: rows[0] }, { status: 201 })
return NextResponse.json({ uploads: inserted }, { status: 201 })
}
+138 -62
View File
@@ -1,12 +1,14 @@
'use client'
import { useEffect, useState, useRef } from 'react'
import { FileText, Download, Upload, Eye, LogOut } from 'lucide-react'
import { useEffect, useState, useRef, useCallback } from 'react'
import { FileText, Download, Upload, Eye, LogOut, Pencil, Globe } from 'lucide-react'
interface Doc {
id: string
filename: string
display_name: string | null
description_de: string | null
description_en: string | null
mime_type: string
file_size: number
released_at: string
@@ -16,6 +18,8 @@ interface MyUpload {
id: string
filename: string
display_name: string | null
description_de: string | null
description_en: string | null
mime_type: string
file_size: number
created_at: string
@@ -27,57 +31,83 @@ function fmt(bytes: number) {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function isPDF(mime: string) {
return mime === 'application/pdf'
function isPDF(mime: string) { return mime === 'application/pdf' }
function LangToggle({ lang, onChange }: { lang: 'de' | 'en'; onChange: (l: 'de' | 'en') => void }) {
return (
<div className="flex rounded-lg overflow-hidden border border-white/10 text-xs shrink-0">
{(['de', 'en'] as const).map(l => (
<button key={l} onClick={() => onChange(l)}
className={`px-2 py-1 uppercase transition-colors ${lang === l ? 'bg-indigo-500/30 text-indigo-300' : 'text-white/40 hover:text-white/70'}`}>
{l}
</button>
))}
</div>
)
}
export default function DataroomPage() {
const [docs, setDocs] = useState<Doc[]>([])
const [uploads, setUploads] = useState<MyUpload[]>([])
const [dragging, setDragging] = useState(false)
const [uploading, setUploading] = useState(false)
const [toast, setToast] = useState<string | null>(null)
const [description, setDescription] = useState('')
const [descLang, setDescLang] = useState<'de' | 'en'>('en')
const [editingUpload, setEditingUpload] = useState<{ id: string; text: string; lang: 'de' | 'en' } | null>(null)
const fileRef = useRef<HTMLInputElement>(null)
function flash(msg: string) {
setToast(msg)
setTimeout(() => setToast(null), 3500)
}
function flash(msg: string) { setToast(msg); setTimeout(() => setToast(null), 3500) }
async function loadAll() {
const [dr, ur] = await Promise.all([
fetch('/api/dataroom/documents'),
fetch('/api/dataroom/uploads'),
])
const [dr, ur] = await Promise.all([fetch('/api/dataroom/documents'), fetch('/api/dataroom/uploads')])
if (dr.ok) setDocs((await dr.json()).documents)
if (ur.ok) setUploads((await ur.json()).uploads)
}
useEffect(() => { loadAll() }, [])
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
async function uploadFiles(files: FileList | File[]) {
const list = Array.from(files).filter(f => f.size > 0)
if (!list.length) return
setUploading(true)
const fd = new FormData()
fd.append('file', file)
list.forEach(f => fd.append('file', f))
if (description.trim()) { fd.append('description', description.trim()); fd.append('description_lang', descLang) }
const r = await fetch('/api/dataroom/uploads', { method: 'POST', body: fd })
setUploading(false)
if (r.ok) { flash('File uploaded successfully'); loadAll() }
else {
if (r.ok) {
flash(`${list.length} file${list.length > 1 ? 's' : ''} uploaded`)
setDescription(''); loadAll()
} else {
const d = await r.json().catch(() => ({}))
flash(d.error || 'Upload failed')
}
e.target.value = ''
}
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault(); setDragging(false)
if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files)
}, [description, descLang])
async function saveEditDescription() {
if (!editingUpload) return
const r = await fetch(`/api/dataroom/uploads/${editingUpload.id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: editingUpload.text || null, description_lang: editingUpload.lang }),
})
if (r.ok) {
const updated = (await r.json()).upload as MyUpload
setUploads(prev => prev.map(u => u.id === updated.id ? updated : u))
setEditingUpload(null); flash('Description saved & translated')
} else flash('Save failed')
}
return (
<div className="min-h-screen bg-[#0a0a1a] text-white">
{/* Header */}
<div className="border-b border-white/[0.06] px-6 py-4 flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">
Data Room
</h1>
<h1 className="text-lg font-semibold bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">Data Room</h1>
<p className="text-xs text-white/30 mt-0.5">BreakPilot ComplAI · Investor Portal</p>
</div>
<div className="flex items-center gap-3">
@@ -101,32 +131,30 @@ export default function DataroomPage() {
) : (
<div className="space-y-3">
{docs.map(doc => (
<div key={doc.id} className="bg-white/[0.04] border border-white/[0.06] rounded-xl p-4 flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-indigo-500/10 flex items-center justify-center shrink-0">
<div key={doc.id} className="bg-white/[0.04] border border-white/[0.06] rounded-xl p-4 flex items-start gap-4">
<div className="w-10 h-10 rounded-lg bg-indigo-500/10 flex items-center justify-center shrink-0 mt-0.5">
<FileText className="w-5 h-5 text-indigo-400" />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-white truncate">{doc.display_name || doc.filename}</div>
<div className="text-xs text-white/40 mt-0.5">
{fmt(doc.file_size)} · Released {new Date(doc.released_at).toLocaleDateString()}
<div className="text-sm font-medium text-white">{doc.display_name || doc.filename}</div>
<div className="text-xs text-white/40 mt-0.5">{fmt(doc.file_size)} · Released {new Date(doc.released_at).toLocaleDateString()}</div>
{(doc.description_en || doc.description_de) && (
<div className="mt-1.5 space-y-0.5">
{doc.description_en && <p className="text-xs text-white/40 leading-relaxed">{doc.description_en}</p>}
{doc.description_de && !doc.description_en && <p className="text-xs text-white/40 leading-relaxed">{doc.description_de}</p>}
{doc.description_de && doc.description_en && <p className="text-xs text-white/20 leading-relaxed flex gap-1"><Globe className="w-3 h-3 shrink-0 mt-0.5" />{doc.description_de}</p>}
</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{isPDF(doc.mime_type) && (
<a
href={`/api/dataroom/documents/${doc.id}/download?preview=1`}
target="_blank"
rel="noopener noreferrer"
className="bg-white/[0.06] hover:bg-white/[0.1] text-white/70 text-xs px-3 py-1.5 rounded-lg flex items-center gap-1.5 transition-colors"
>
<a href={`/api/dataroom/documents/${doc.id}/download?preview=1`} target="_blank" rel="noopener noreferrer"
className="bg-white/[0.06] hover:bg-white/[0.1] text-white/70 text-xs px-3 py-1.5 rounded-lg flex items-center gap-1.5 transition-colors">
<Eye className="w-3.5 h-3.5" /> Preview
</a>
)}
<a
href={`/api/dataroom/documents/${doc.id}/download`}
className="bg-indigo-500/15 hover:bg-indigo-500/25 text-indigo-300 text-xs px-3 py-1.5 rounded-lg flex items-center gap-1.5 transition-colors"
download
>
<a href={`/api/dataroom/documents/${doc.id}/download`} download
className="bg-indigo-500/15 hover:bg-indigo-500/25 text-indigo-300 text-xs px-3 py-1.5 rounded-lg flex items-center gap-1.5 transition-colors">
<Download className="w-3.5 h-3.5" /> Download
</a>
</div>
@@ -138,40 +166,88 @@ export default function DataroomPage() {
{/* Upload section */}
<section>
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-white/60 uppercase tracking-wider">Your Documents</h2>
<button
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="bg-indigo-500 hover:bg-indigo-600 disabled:opacity-50 text-white text-sm px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"
>
<Upload className="w-4 h-4" />
{uploading ? 'Uploading…' : 'Send document'}
</button>
<input ref={fileRef} type="file" className="hidden" onChange={handleUpload} />
</div>
<h2 className="text-sm font-semibold text-white/60 uppercase tracking-wider mb-4">Your Documents</h2>
<p className="text-xs text-white/30 mb-4">
Upload documents you want to share with us NDAs, term sheets, financial statements, or any other relevant files.
</p>
{uploads.length === 0 ? (
<div className="bg-white/[0.02] border border-dashed border-white/[0.08] rounded-2xl p-10 text-center">
<Upload className="w-7 h-7 text-white/20 mx-auto mb-3" />
<p className="text-white/30 text-sm">No files uploaded yet.</p>
{/* Description field */}
<div className="flex gap-2 items-start mb-3">
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Optional description for uploaded files…"
rows={2}
className="flex-1 bg-white/[0.04] border border-white/[0.06] rounded-xl px-4 py-3 text-sm text-white placeholder-white/20 resize-none focus:outline-none focus:border-indigo-500/30"
/>
<LangToggle lang={descLang} onChange={setDescLang} />
</div>
) : (
<div className="space-y-2">
{uploads.map(u => (
<div key={u.id} className="bg-white/[0.03] border border-white/[0.05] rounded-xl p-4 flex items-center gap-3">
{/* Drop zone */}
<div
onDragOver={e => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
onClick={() => fileRef.current?.click()}
className={`border-2 border-dashed rounded-2xl p-10 text-center cursor-pointer transition-all select-none ${dragging ? 'border-indigo-400/60 bg-indigo-500/10' : 'border-white/[0.08] bg-white/[0.02] hover:border-white/20 hover:bg-white/[0.03]'}`}
>
{uploading
? <p className="text-white/40 text-sm">Uploading</p>
: <>
<Upload className="w-7 h-7 text-white/20 mx-auto mb-3" />
<p className="text-white/50 text-sm font-medium">Drop files here</p>
<p className="text-white/25 text-xs mt-1">or click to browse · multiple files supported</p>
</>}
<input ref={fileRef} type="file" multiple className="hidden" onChange={e => { if (e.target.files) uploadFiles(e.target.files); e.target.value = '' }} />
</div>
{/* Uploaded files list */}
{uploads.length > 0 && (
<div className="mt-4 space-y-2">
{uploads.map(u => {
const isEditing = editingUpload?.id === u.id
return (
<div key={u.id} className="bg-white/[0.03] border border-white/[0.05] rounded-xl p-4 space-y-2">
<div className="flex items-center gap-3">
<FileText className="w-4 h-4 text-white/30 shrink-0" />
<div className="min-w-0 flex-1">
<div className="text-sm text-white/80 truncate">{u.display_name || u.filename}</div>
<div className="text-xs text-white/30 mt-0.5">{fmt(u.file_size)} · {new Date(u.created_at).toLocaleString()}</div>
</div>
<span className="text-xs text-emerald-400/70 shrink-0">Received</span>
<div className="flex items-center gap-2 shrink-0">
<button onClick={() => isEditing ? setEditingUpload(null) : setEditingUpload({ id: u.id, text: u.description_en || u.description_de || '', lang: u.description_en ? 'en' : 'de' })}
className="text-white/25 hover:text-white/60 transition-colors">
<Pencil className="w-3.5 h-3.5" />
</button>
<span className="text-xs text-emerald-400/70">Received</span>
</div>
))}
</div>
{(u.description_en || u.description_de) && !isEditing && (
<div className="pl-7 space-y-0.5">
{u.description_en && <p className="text-xs text-white/30">{u.description_en}</p>}
{u.description_de && !u.description_en && <p className="text-xs text-white/30">{u.description_de}</p>}
</div>
)}
{isEditing && editingUpload && (
<div className="pl-7 space-y-2">
<div className="flex gap-2 items-start">
<textarea value={editingUpload.text} onChange={e => setEditingUpload(d => d ? { ...d, text: e.target.value } : d)}
rows={2} placeholder="Description…"
className="flex-1 bg-white/[0.04] border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/20 resize-none focus:outline-none focus:border-indigo-500/30" />
<LangToggle lang={editingUpload.lang} onChange={l => setEditingUpload(d => d ? { ...d, lang: l } : d)} />
</div>
<div className="flex gap-2">
<button onClick={saveEditDescription}
className="text-xs bg-indigo-500/20 hover:bg-indigo-500/30 text-indigo-300 px-3 py-1.5 rounded-lg">
Save & translate
</button>
<button onClick={() => setEditingUpload(null)} className="text-xs text-white/30 hover:text-white/60 px-2">Cancel</button>
</div>
</div>
)}
</div>
)
})}
</div>
)}
</section>
@@ -1,12 +1,14 @@
'use client'
import { useEffect, useState, useRef } from 'react'
import { Upload, FileText, Trash2, X, Share2, Users, ChevronDown, Check, Download } from 'lucide-react'
import { useEffect, useState, useRef, useCallback } from 'react'
import { FileText, Trash2, X, Share2, Users, Check, Download, Pencil, Globe } from 'lucide-react'
interface Doc {
id: string
filename: string
display_name: string
description_de: string | null
description_en: string | null
mime_type: string
file_size: number
uploaded_by: string
@@ -37,6 +39,8 @@ interface InvestorUpload {
id: string
filename: string
display_name: string
description_de: string | null
description_en: string | null
mime_type: string
file_size: number
created_at: string
@@ -48,22 +52,35 @@ function fmt(bytes: number) {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function LangToggle({ lang, onChange }: { lang: 'de' | 'en'; onChange: (l: 'de' | 'en') => void }) {
return (
<div className="flex rounded-lg overflow-hidden border border-white/10 text-xs shrink-0">
{(['de', 'en'] as const).map(l => (
<button key={l} onClick={() => onChange(l)}
className={`px-2 py-1 uppercase transition-colors ${lang === l ? 'bg-indigo-500/30 text-indigo-300' : 'text-white/40 hover:text-white/70'}`}>
{l}
</button>
))}
</div>
)
}
export default function DataroomPage() {
const [docs, setDocs] = useState<Doc[]>([])
const [investors, setInvestors] = useState<Investor[]>([])
const [selected, setSelected] = useState<Doc | null>(null)
const [releases, setReleases] = useState<Release[]>([])
const [dragging, setDragging] = useState(false)
const [uploading, setUploading] = useState(false)
const [busy, setBusy] = useState(false)
const [toast, setToast] = useState<string | null>(null)
const [tab, setTab] = useState<'documents' | 'uploads'>('documents')
const [investorUploads, setInvestorUploads] = useState<Record<string, InvestorUpload[]>>({})
const [descEdit, setDescEdit] = useState<{ text: string; lang: 'de' | 'en' } | null>(null)
const [editingUpload, setEditingUpload] = useState<{ invId: string; uploadId: string; text: string; lang: 'de' | 'en' } | null>(null)
const fileRef = useRef<HTMLInputElement>(null)
function flash(msg: string) {
setToast(msg)
setTimeout(() => setToast(null), 3000)
}
function flash(msg: string) { setToast(msg); setTimeout(() => setToast(null), 3000) }
async function loadDocs() {
const r = await fetch('/api/admin/dataroom/documents')
@@ -96,23 +113,27 @@ export default function DataroomPage() {
useEffect(() => { if (tab === 'uploads' && investors.length > 0) loadInvestorUploads() }, [tab, investors])
async function selectDoc(doc: Doc) {
setSelected(doc)
setSelected(doc); setDescEdit(null)
await loadReleases(doc.id)
}
async function uploadFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
async function uploadFiles(files: FileList | File[]) {
const list = Array.from(files).filter(f => f.size > 0)
if (!list.length) return
setUploading(true)
const fd = new FormData()
fd.append('file', file)
list.forEach(f => fd.append('file', f))
const r = await fetch('/api/admin/dataroom/documents', { method: 'POST', body: fd })
setUploading(false)
if (r.ok) { flash('Uploaded'); loadDocs() }
if (r.ok) { flash(`Uploaded ${list.length} file${list.length > 1 ? 's' : ''}`); loadDocs() }
else { const d = await r.json().catch(() => ({})); flash(d.error || 'Upload failed') }
e.target.value = ''
}
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault(); setDragging(false)
if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files)
}, [])
async function deleteDoc(id: string) {
if (!confirm('Delete this document? All releases will be removed.')) return
setBusy(true)
@@ -129,27 +150,55 @@ export default function DataroomPage() {
await fetch(`/api/admin/dataroom/documents/${selected.id}/release/${investorId}`, { method: 'DELETE' })
} else {
await fetch(`/api/admin/dataroom/documents/${selected.id}/release`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ investor_ids: [investorId] }),
})
}
setBusy(false)
await loadReleases(selected.id)
loadDocs()
setBusy(false); await loadReleases(selected.id); loadDocs()
}
async function releaseAll() {
if (!selected || investors.length === 0) return
setBusy(true)
await fetch(`/api/admin/dataroom/documents/${selected.id}/release`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ investor_ids: investors.map(i => i.id) }),
})
setBusy(false); await loadReleases(selected.id); loadDocs()
}
async function saveDescription() {
if (!selected || !descEdit) return
setBusy(true)
const r = await fetch(`/api/admin/dataroom/documents/${selected.id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: descEdit.text || null, description_lang: descEdit.lang }),
})
setBusy(false)
await loadReleases(selected.id)
loadDocs()
if (r.ok) {
const updated = (await r.json()).document as Doc
setDocs(prev => prev.map(d => d.id === updated.id ? { ...d, ...updated } : d))
setSelected(prev => prev ? { ...prev, ...updated } : prev)
setDescEdit(null); flash('Description saved & translated')
} else flash('Save failed')
}
async function saveUploadDescription() {
if (!editingUpload) return
setBusy(true)
const r = await fetch(`/api/admin/dataroom/investors/${editingUpload.invId}/uploads`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ upload_id: editingUpload.uploadId, description: editingUpload.text || null, description_lang: editingUpload.lang }),
})
setBusy(false)
if (r.ok) {
const updated = (await r.json()).upload as InvestorUpload
setInvestorUploads(prev => ({
...prev,
[editingUpload.invId]: (prev[editingUpload.invId] || []).map(u => u.id === updated.id ? updated : u),
}))
setEditingUpload(null); flash('Description saved & translated')
} else flash('Save failed')
}
const releasedIds = new Set(releases.map(r => r.investor_id))
@@ -161,55 +210,50 @@ export default function DataroomPage() {
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-white">Data Room</h1>
<div className="flex gap-2">
<button
onClick={() => setTab('documents')}
className={`text-sm px-4 py-2 rounded-lg transition-colors ${tab === 'documents' ? 'bg-indigo-500/20 text-indigo-300' : 'text-white/50 hover:text-white/80'}`}
>
Documents
</button>
<button
onClick={() => setTab('uploads')}
className={`text-sm px-4 py-2 rounded-lg transition-colors ${tab === 'uploads' ? 'bg-indigo-500/20 text-indigo-300' : 'text-white/50 hover:text-white/80'}`}
>
Investor Uploads
{(['documents', 'uploads'] as const).map(t => (
<button key={t} onClick={() => setTab(t)}
className={`text-sm px-4 py-2 rounded-lg transition-colors ${tab === t ? 'bg-indigo-500/20 text-indigo-300' : 'text-white/50 hover:text-white/80'}`}>
{t === 'documents' ? 'Documents' : 'Investor Uploads'}
</button>
))}
</div>
</div>
{tab === 'documents' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Document list */}
{/* Drop zone + document list */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-white/50">{docs.length} document{docs.length !== 1 ? 's' : ''}</span>
<button
<div
onDragOver={e => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="bg-indigo-500 hover:bg-indigo-600 disabled:opacity-50 text-white text-sm px-4 py-2 rounded-lg flex items-center gap-2"
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all select-none ${dragging ? 'border-indigo-400/60 bg-indigo-500/10' : 'border-white/[0.08] bg-white/[0.02] hover:border-white/20 hover:bg-white/[0.03]'}`}
>
<Upload className="w-4 h-4" />
{uploading ? 'Uploading…' : 'Upload'}
</button>
<input ref={fileRef} type="file" className="hidden" onChange={uploadFile} />
{uploading
? <p className="text-white/40 text-sm">Uploading</p>
: <>
<p className="text-white/50 text-sm font-medium">Drop files here</p>
<p className="text-white/25 text-xs mt-1">or click to browse · multiple files supported</p>
</>}
<input ref={fileRef} type="file" multiple className="hidden" onChange={e => { if (e.target.files) uploadFiles(e.target.files); e.target.value = '' }} />
</div>
{docs.length === 0 && (
<div className="bg-white/[0.03] border border-dashed border-white/10 rounded-xl p-10 text-center text-white/30 text-sm">
No documents yet. Upload the first one.
</div>
{docs.length > 0 && (
<p className="text-xs text-white/30">{docs.length} document{docs.length !== 1 ? 's' : ''}</p>
)}
{docs.map(doc => (
<button
key={doc.id}
onClick={() => selectDoc(doc)}
className={`w-full text-left bg-white/[0.03] border rounded-xl p-4 transition-colors ${selected?.id === doc.id ? 'border-indigo-500/40 bg-indigo-500/5' : 'border-white/[0.06] hover:border-white/[0.12]'}`}
>
<button key={doc.id} onClick={() => selectDoc(doc)}
className={`w-full text-left bg-white/[0.03] border rounded-xl p-4 transition-colors ${selected?.id === doc.id ? 'border-indigo-500/40 bg-indigo-500/5' : 'border-white/[0.06] hover:border-white/[0.12]'}`}>
<div className="flex items-start gap-3">
<FileText className="w-5 h-5 text-indigo-400 mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-white truncate">{doc.display_name || doc.filename}</div>
<div className="text-xs text-white/40 mt-0.5">{fmt(doc.file_size)} · {new Date(doc.created_at).toLocaleDateString()}</div>
{(doc.description_en || doc.description_de) && (
<div className="text-xs text-white/30 mt-1 line-clamp-1">{doc.description_en || doc.description_de}</div>
)}
</div>
<span className="text-xs text-white/40 shrink-0">
{doc.release_count > 0 ? <span className="text-emerald-400">{doc.release_count} released</span> : 'not released'}
@@ -219,7 +263,7 @@ export default function DataroomPage() {
))}
</div>
{/* Release panel */}
{/* Detail panel */}
{selected ? (
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5 space-y-4">
<div className="flex items-start justify-between gap-3">
@@ -228,19 +272,13 @@ export default function DataroomPage() {
<div className="text-xs text-white/40 mt-0.5">{fmt(selected.file_size)} · {selected.mime_type}</div>
</div>
<div className="flex gap-2 shrink-0">
<button
onClick={releaseAll}
disabled={busy || allInvestors.length === 0}
className="text-xs bg-emerald-500/15 hover:bg-emerald-500/25 text-emerald-300 px-3 py-1.5 rounded-lg flex items-center gap-1.5 disabled:opacity-40"
>
<button onClick={releaseAll} disabled={busy || allInvestors.length === 0}
className="text-xs bg-emerald-500/15 hover:bg-emerald-500/25 text-emerald-300 px-3 py-1.5 rounded-lg flex items-center gap-1.5 disabled:opacity-40">
<Share2 className="w-3.5 h-3.5" /> Release all
</button>
<button
onClick={() => deleteDoc(selected.id)}
disabled={busy}
className="text-xs bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 px-3 py-1.5 rounded-lg flex items-center gap-1.5 disabled:opacity-40"
>
<Trash2 className="w-3.5 h-3.5" /> Delete
<button onClick={() => deleteDoc(selected.id)} disabled={busy}
className="text-xs bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 px-3 py-1.5 rounded-lg flex items-center gap-1.5 disabled:opacity-40">
<Trash2 className="w-3.5 h-3.5" />
</button>
<button onClick={() => setSelected(null)} className="text-white/40 hover:text-white/80">
<X className="w-4 h-4" />
@@ -248,24 +286,63 @@ export default function DataroomPage() {
</div>
</div>
{/* Description editor */}
<div className="border-t border-white/[0.06] pt-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Globe className="w-3.5 h-3.5 text-white/30" />
<span className="text-xs font-semibold text-white/50 uppercase tracking-wider">Description</span>
</div>
{!descEdit && (
<button onClick={() => setDescEdit({ text: selected.description_en || selected.description_de || '', lang: selected.description_en ? 'en' : 'de' })}
className="text-xs text-white/30 hover:text-white/60 flex items-center gap-1">
<Pencil className="w-3 h-3" /> Edit
</button>
)}
</div>
{descEdit ? (
<div className="space-y-2">
<div className="flex gap-2 items-start">
<textarea
value={descEdit.text}
onChange={e => setDescEdit(d => d ? { ...d, text: e.target.value } : d)}
rows={3}
placeholder="Enter description…"
className="flex-1 bg-white/[0.04] border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/20 resize-none focus:outline-none focus:border-indigo-500/40"
/>
<LangToggle lang={descEdit.lang} onChange={l => setDescEdit(d => d ? { ...d, lang: l } : d)} />
</div>
<div className="flex gap-2">
<button onClick={saveDescription} disabled={busy}
className="text-xs bg-indigo-500/20 hover:bg-indigo-500/30 text-indigo-300 px-3 py-1.5 rounded-lg disabled:opacity-40">
Save & translate
</button>
<button onClick={() => setDescEdit(null)} className="text-xs text-white/30 hover:text-white/60 px-3 py-1.5">Cancel</button>
</div>
</div>
) : (selected.description_en || selected.description_de) ? (
<div className="space-y-1.5">
{selected.description_de && <p className="text-xs text-white/40 leading-relaxed"><span className="text-white/20 uppercase text-[10px] mr-1">DE</span>{selected.description_de}</p>}
{selected.description_en && <p className="text-xs text-white/40 leading-relaxed"><span className="text-white/20 uppercase text-[10px] mr-1">EN</span>{selected.description_en}</p>}
</div>
) : (
<p className="text-xs text-white/20">No description yet.</p>
)}
</div>
{/* Investor access */}
<div className="border-t border-white/[0.06] pt-4">
<div className="flex items-center gap-2 mb-3">
<Users className="w-4 h-4 text-white/40" />
<span className="text-xs font-semibold text-white/60 uppercase tracking-wider">Investor Access</span>
</div>
{allInvestors.length === 0 && (
<p className="text-xs text-white/30">No active investors yet.</p>
)}
{allInvestors.length === 0 && <p className="text-xs text-white/30">No active investors yet.</p>}
<div className="space-y-2">
{allInvestors.map(inv => {
const has = releasedIds.has(inv.id)
return (
<button
key={inv.id}
onClick={() => toggleRelease(inv.id, has)}
disabled={busy}
className="w-full flex items-center gap-3 p-2.5 rounded-lg hover:bg-white/[0.04] transition-colors disabled:opacity-50"
>
<button key={inv.id} onClick={() => toggleRelease(inv.id, has)} disabled={busy}
className="w-full flex items-center gap-3 p-2.5 rounded-lg hover:bg-white/[0.04] transition-colors disabled:opacity-50">
<div className={`w-5 h-5 rounded border flex items-center justify-center shrink-0 ${has ? 'bg-emerald-500 border-emerald-500' : 'border-white/20'}`}>
{has && <Check className="w-3 h-3 text-white" />}
</div>
@@ -310,22 +387,53 @@ export default function DataroomPage() {
<span className="ml-auto text-xs text-white/40">{uploads.length} file{uploads.length !== 1 ? 's' : ''}</span>
</div>
<div className="space-y-2">
{uploads.map(u => (
<div key={u.id} className="flex items-center gap-3 p-2.5 bg-white/[0.03] rounded-lg">
{uploads.map(u => {
const isEditing = editingUpload?.uploadId === u.id
return (
<div key={u.id} className="bg-white/[0.03] rounded-lg p-3 space-y-2">
<div className="flex items-center gap-3">
<FileText className="w-4 h-4 text-white/40 shrink-0" />
<div className="min-w-0 flex-1">
<div className="text-sm text-white truncate">{u.display_name || u.filename}</div>
<div className="text-xs text-white/40">{fmt(u.file_size)} · {new Date(u.created_at).toLocaleString()}</div>
</div>
<a
href={`/api/admin/dataroom/investors/${inv.id}/uploads?download=${u.id}`}
className="text-xs bg-white/[0.06] hover:bg-white/[0.1] text-white/70 px-3 py-1.5 rounded-lg flex items-center gap-1.5"
download
>
<Download className="w-3.5 h-3.5" /> Download
<div className="flex items-center gap-2 shrink-0">
<button onClick={() => isEditing ? setEditingUpload(null) : setEditingUpload({ invId: inv.id, uploadId: u.id, text: u.description_en || u.description_de || '', lang: u.description_en ? 'en' : 'de' })}
className="text-xs text-white/30 hover:text-white/60 flex items-center gap-1">
<Pencil className="w-3 h-3" />
</button>
<a href={`/api/admin/dataroom/investors/${inv.id}/uploads?download=${u.id}`} download
className="text-xs bg-white/[0.06] hover:bg-white/[0.1] text-white/70 px-3 py-1.5 rounded-lg flex items-center gap-1.5">
<Download className="w-3.5 h-3.5" />
</a>
</div>
))}
</div>
{(u.description_de || u.description_en) && !isEditing && (
<div className="pl-7 space-y-1">
{u.description_de && <p className="text-xs text-white/30"><span className="text-white/15 uppercase text-[10px] mr-1">DE</span>{u.description_de}</p>}
{u.description_en && <p className="text-xs text-white/30"><span className="text-white/15 uppercase text-[10px] mr-1">EN</span>{u.description_en}</p>}
</div>
)}
{isEditing && editingUpload && (
<div className="pl-7 space-y-2">
<div className="flex gap-2 items-start">
<textarea value={editingUpload.text} onChange={e => setEditingUpload(d => d ? { ...d, text: e.target.value } : d)}
rows={2} placeholder="Description…"
className="flex-1 bg-white/[0.04] border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/20 resize-none focus:outline-none focus:border-indigo-500/40" />
<LangToggle lang={editingUpload.lang} onChange={l => setEditingUpload(d => d ? { ...d, lang: l } : d)} />
</div>
<div className="flex gap-2">
<button onClick={saveUploadDescription} disabled={busy}
className="text-xs bg-indigo-500/20 hover:bg-indigo-500/30 text-indigo-300 px-3 py-1.5 rounded-lg disabled:opacity-40">
Save & translate
</button>
<button onClick={() => setEditingUpload(null)} className="text-xs text-white/30 hover:text-white/60 px-2">Cancel</button>
</div>
</div>
)}
</div>
)
})}
</div>
</div>
)
+28
View File
@@ -0,0 +1,28 @@
const LITELLM_URL = process.env.LITELLM_URL || 'https://llm-dev.meghsakha.com'
const LITELLM_MODEL = process.env.LITELLM_MODEL || 'gpt-oss-120b'
const LITELLM_API_KEY = process.env.LITELLM_API_KEY || ''
export async function translateText(text: string, from: 'de' | 'en'): Promise<string | null> {
if (!text.trim()) return null
const toLang = from === 'de' ? 'English' : 'German'
try {
const r = await fetch(`${LITELLM_URL}/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${LITELLM_API_KEY}` },
body: JSON.stringify({
model: LITELLM_MODEL,
messages: [
{ role: 'system', content: `Translate the following text to ${toLang}. Output only the translated text, nothing else.` },
{ role: 'user', content: text },
],
max_tokens: 1000,
temperature: 0.1,
}),
})
if (!r.ok) return null
const data = await r.json()
return (data.choices?.[0]?.message?.content as string | undefined)?.trim() || null
} catch {
return null
}
}