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 { requireAdmin, getAdminFromCookie } from '@/lib/admin-auth'
import { adminDocDir, removeDir } from '@/lib/dataroom-storage' import { adminDocDir, removeDir } from '@/lib/dataroom-storage'
import { logAudit } from '@/lib/auth' import { logAudit } from '@/lib/auth'
import { translateText } from '@/lib/translate'
interface Ctx { params: Promise<{ id: string }> } 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 if (guard.kind === 'response') return guard.response
const { id } = await ctx.params 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( const { rows } = await pool.query(
`UPDATE dataroom_documents SET display_name = $1, updated_at = NOW() `UPDATE dataroom_documents SET display_name = $1, updated_at = NOW()
WHERE id = $2 RETURNING *`, WHERE id = $2 RETURNING *`,
@@ -3,6 +3,7 @@ import pool from '@/lib/db'
import { requireAdmin, getAdminFromCookie } from '@/lib/admin-auth' import { requireAdmin, getAdminFromCookie } from '@/lib/admin-auth'
import { adminDocDir, saveFile, safeName } from '@/lib/dataroom-storage' import { adminDocDir, saveFile, safeName } from '@/lib/dataroom-storage'
import { logAudit } from '@/lib/auth' import { logAudit } from '@/lib/auth'
import { translateText } from '@/lib/translate'
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -12,7 +13,8 @@ export async function GET(request: NextRequest) {
if (guard.kind === 'response') return guard.response if (guard.kind === 'response') return guard.response
const { rows } = await pool.query( 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 COUNT(r.id)::int AS release_count
FROM dataroom_documents d FROM dataroom_documents d
LEFT JOIN dataroom_releases r ON r.document_id = d.id 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 admin = await getAdminFromCookie()
const formData = await request.formData() 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) { const files = formData.getAll('file') as File[]
return NextResponse.json({ error: 'No file provided' }, { status: 400 }) 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 documentId = randomUUID()
const filename = safeName(file.name) const filename = safeName(file.name)
const buffer = Buffer.from(await file.arrayBuffer()) const buffer = Buffer.from(await file.arrayBuffer())
const filePath = await saveFile(adminDocDir(documentId), filename, buffer) const filePath = await saveFile(adminDocDir(documentId), filename, buffer)
const { rows } = await pool.query( const { rows } = await pool.query(
`INSERT INTO dataroom_documents (id, filename, file_path, display_name, mime_type, file_size, uploaded_by) `INSERT INTO dataroom_documents
VALUES ($1, $2, $3, $4, $5, $6, $7) (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 *`, 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) 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 pool from '@/lib/db'
import { requireAdmin } from '@/lib/admin-auth' import { requireAdmin } from '@/lib/admin-auth'
import { streamFile } from '@/lib/dataroom-storage' import { streamFile } from '@/lib/dataroom-storage'
import { translateText } from '@/lib/translate'
import path from 'path' import path from 'path'
interface Ctx { params: Promise<{ id: string }> } interface Ctx { params: Promise<{ id: string }> }
@@ -31,7 +32,7 @@ export async function GET(request: NextRequest, ctx: Ctx) {
} }
const { rows } = await pool.query( 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 FROM dataroom_investor_uploads
WHERE investor_id = $1 WHERE investor_id = $1
ORDER BY created_at DESC`, ORDER BY created_at DESC`,
@@ -39,3 +40,25 @@ export async function GET(request: NextRequest, ctx: Ctx) {
) )
return NextResponse.json({ uploads: rows }) 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_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_releases_document ON dataroom_releases(document_id)`,
`CREATE INDEX IF NOT EXISTS idx_dataroom_uploads_investor ON dataroom_investor_uploads(investor_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) { for (const sql of statements) {
@@ -4,13 +4,14 @@ import { getSessionFromCookie } from '@/lib/auth'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export async function GET(request: NextRequest) { export async function GET(_request: NextRequest) {
const session = await getSessionFromCookie() const session = await getSessionFromCookie()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const investorId = session.sub const investorId = session.sub
const { rows } = await pool.query( 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 FROM dataroom_releases r
JOIN dataroom_documents d ON d.id = r.document_id JOIN dataroom_documents d ON d.id = r.document_id
WHERE r.investor_id = $1 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 pool from '@/lib/db'
import { investorUploadDir, saveFile, safeName } from '@/lib/dataroom-storage' import { investorUploadDir, saveFile, safeName } from '@/lib/dataroom-storage'
import { logAudit, getSessionFromCookie } from '@/lib/auth' import { logAudit, getSessionFromCookie } from '@/lib/auth'
import { translateText } from '@/lib/translate'
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -14,7 +15,7 @@ export async function GET(_request: NextRequest) {
const investorId = session.sub const investorId = session.sub
const { rows } = await pool.query( 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 FROM dataroom_investor_uploads
WHERE investor_id = $1 WHERE investor_id = $1
ORDER BY created_at DESC`, ORDER BY created_at DESC`,
@@ -30,24 +31,42 @@ export async function POST(request: NextRequest) {
const sessionId = session.sessionId const sessionId = session.sessionId
const formData = await request.formData() const formData = await request.formData()
const file = formData.get('file') as File | null const files = formData.getAll('file') as File[]
const displayName = (formData.get('display_name') as string | null) || null 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 }) const oversized = validFiles.find(f => f.size > MAX_BYTES)
if (file.size > MAX_BYTES) return NextResponse.json({ error: `File exceeds ${process.env.DATAROOM_MAX_UPLOAD_MB || 50}MB limit` }, { status: 413 }) 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 uploadId = randomUUID()
const filename = safeName(file.name) const filename = safeName(file.name)
const buffer = Buffer.from(await file.arrayBuffer()) const buffer = Buffer.from(await file.arrayBuffer())
const filePath = await saveFile(investorUploadDir(investorId, uploadId), filename, buffer) const filePath = await saveFile(investorUploadDir(investorId, uploadId), filename, buffer)
const { rows } = await pool.query( const { rows } = await pool.query(
`INSERT INTO dataroom_investor_uploads (id, investor_id, filename, file_path, display_name, mime_type, file_size) `INSERT INTO dataroom_investor_uploads
VALUES ($1, $2, $3, $4, $5, $6, $7) (id, investor_id, filename, file_path, display_name, description_de, description_en, mime_type, file_size)
RETURNING id, filename, display_name, mime_type, file_size, created_at`, VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
[uploadId, investorId, filename, filePath, displayName || file.name, file.type || 'application/octet-stream', file.size], 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({ uploads: inserted }, { status: 201 })
return NextResponse.json({ upload: rows[0] }, { status: 201 })
} }
+138 -62
View File
@@ -1,12 +1,14 @@
'use client' 'use client'
import { useEffect, useState, useRef } from 'react' import { useEffect, useState, useRef, useCallback } from 'react'
import { FileText, Download, Upload, Eye, LogOut } from 'lucide-react' import { FileText, Download, Upload, Eye, LogOut, Pencil, Globe } from 'lucide-react'
interface Doc { interface Doc {
id: string id: string
filename: string filename: string
display_name: string | null display_name: string | null
description_de: string | null
description_en: string | null
mime_type: string mime_type: string
file_size: number file_size: number
released_at: string released_at: string
@@ -16,6 +18,8 @@ interface MyUpload {
id: string id: string
filename: string filename: string
display_name: string | null display_name: string | null
description_de: string | null
description_en: string | null
mime_type: string mime_type: string
file_size: number file_size: number
created_at: string created_at: string
@@ -27,57 +31,83 @@ function fmt(bytes: number) {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB` return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
} }
function isPDF(mime: string) { function isPDF(mime: string) { return mime === 'application/pdf' }
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() { export default function DataroomPage() {
const [docs, setDocs] = useState<Doc[]>([]) const [docs, setDocs] = useState<Doc[]>([])
const [uploads, setUploads] = useState<MyUpload[]>([]) const [uploads, setUploads] = useState<MyUpload[]>([])
const [dragging, setDragging] = useState(false)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [toast, setToast] = useState<string | null>(null) 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) const fileRef = useRef<HTMLInputElement>(null)
function flash(msg: string) { function flash(msg: string) { setToast(msg); setTimeout(() => setToast(null), 3500) }
setToast(msg)
setTimeout(() => setToast(null), 3500)
}
async function loadAll() { async function loadAll() {
const [dr, ur] = await Promise.all([ const [dr, ur] = await Promise.all([fetch('/api/dataroom/documents'), fetch('/api/dataroom/uploads')])
fetch('/api/dataroom/documents'),
fetch('/api/dataroom/uploads'),
])
if (dr.ok) setDocs((await dr.json()).documents) if (dr.ok) setDocs((await dr.json()).documents)
if (ur.ok) setUploads((await ur.json()).uploads) if (ur.ok) setUploads((await ur.json()).uploads)
} }
useEffect(() => { loadAll() }, []) useEffect(() => { loadAll() }, [])
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) { async function uploadFiles(files: FileList | File[]) {
const file = e.target.files?.[0] const list = Array.from(files).filter(f => f.size > 0)
if (!file) return if (!list.length) return
setUploading(true) setUploading(true)
const fd = new FormData() 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 }) const r = await fetch('/api/dataroom/uploads', { method: 'POST', body: fd })
setUploading(false) setUploading(false)
if (r.ok) { flash('File uploaded successfully'); loadAll() } if (r.ok) {
else { flash(`${list.length} file${list.length > 1 ? 's' : ''} uploaded`)
setDescription(''); loadAll()
} else {
const d = await r.json().catch(() => ({})) const d = await r.json().catch(() => ({}))
flash(d.error || 'Upload failed') 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 ( return (
<div className="min-h-screen bg-[#0a0a1a] text-white"> <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 className="border-b border-white/[0.06] px-6 py-4 flex items-center justify-between">
<div> <div>
<h1 className="text-lg font-semibold bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent"> <h1 className="text-lg font-semibold bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">Data Room</h1>
Data Room
</h1>
<p className="text-xs text-white/30 mt-0.5">BreakPilot ComplAI · Investor Portal</p> <p className="text-xs text-white/30 mt-0.5">BreakPilot ComplAI · Investor Portal</p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -101,32 +131,30 @@ export default function DataroomPage() {
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{docs.map(doc => ( {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 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"> <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" /> <FileText className="w-5 h-5 text-indigo-400" />
</div> </div>
<div className="min-w-0 flex-1"> <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-sm font-medium text-white">{doc.display_name || doc.filename}</div>
<div className="text-xs text-white/40 mt-0.5"> <div className="text-xs text-white/40 mt-0.5">{fmt(doc.file_size)} · Released {new Date(doc.released_at).toLocaleDateString()}</div>
{fmt(doc.file_size)} · Released {new Date(doc.released_at).toLocaleDateString()} {(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> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
{isPDF(doc.mime_type) && ( {isPDF(doc.mime_type) && (
<a <a href={`/api/dataroom/documents/${doc.id}/download?preview=1`} target="_blank" rel="noopener noreferrer"
href={`/api/dataroom/documents/${doc.id}/download?preview=1`} 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">
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 <Eye className="w-3.5 h-3.5" /> Preview
</a> </a>
)} )}
<a <a href={`/api/dataroom/documents/${doc.id}/download`} download
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">
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
>
<Download className="w-3.5 h-3.5" /> Download <Download className="w-3.5 h-3.5" /> Download
</a> </a>
</div> </div>
@@ -138,40 +166,88 @@ export default function DataroomPage() {
{/* Upload section */} {/* Upload section */}
<section> <section>
<div className="flex items-center justify-between mb-4"> <h2 className="text-sm font-semibold text-white/60 uppercase tracking-wider mb-4">Your Documents</h2>
<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>
<p className="text-xs text-white/30 mb-4"> <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. Upload documents you want to share with us NDAs, term sheets, financial statements, or any other relevant files.
</p> </p>
{uploads.length === 0 ? ( {/* Description field */}
<div className="bg-white/[0.02] border border-dashed border-white/[0.08] rounded-2xl p-10 text-center"> <div className="flex gap-2 items-start mb-3">
<Upload className="w-7 h-7 text-white/20 mx-auto mb-3" /> <textarea
<p className="text-white/30 text-sm">No files uploaded yet.</p> 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>
) : (
<div className="space-y-2"> {/* Drop zone */}
{uploads.map(u => ( <div
<div key={u.id} className="bg-white/[0.03] border border-white/[0.05] rounded-xl p-4 flex items-center gap-3"> 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" /> <FileText className="w-4 h-4 text-white/30 shrink-0" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-sm text-white/80 truncate">{u.display_name || u.filename}</div> <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 className="text-xs text-white/30 mt-0.5">{fmt(u.file_size)} · {new Date(u.created_at).toLocaleString()}</div>
</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>
))} </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> </div>
)} )}
</section> </section>
@@ -1,12 +1,14 @@
'use client' 'use client'
import { useEffect, useState, useRef } from 'react' import { useEffect, useState, useRef, useCallback } from 'react'
import { Upload, FileText, Trash2, X, Share2, Users, ChevronDown, Check, Download } from 'lucide-react' import { FileText, Trash2, X, Share2, Users, Check, Download, Pencil, Globe } from 'lucide-react'
interface Doc { interface Doc {
id: string id: string
filename: string filename: string
display_name: string display_name: string
description_de: string | null
description_en: string | null
mime_type: string mime_type: string
file_size: number file_size: number
uploaded_by: string uploaded_by: string
@@ -37,6 +39,8 @@ interface InvestorUpload {
id: string id: string
filename: string filename: string
display_name: string display_name: string
description_de: string | null
description_en: string | null
mime_type: string mime_type: string
file_size: number file_size: number
created_at: string created_at: string
@@ -48,22 +52,35 @@ function fmt(bytes: number) {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB` 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() { export default function DataroomPage() {
const [docs, setDocs] = useState<Doc[]>([]) const [docs, setDocs] = useState<Doc[]>([])
const [investors, setInvestors] = useState<Investor[]>([]) const [investors, setInvestors] = useState<Investor[]>([])
const [selected, setSelected] = useState<Doc | null>(null) const [selected, setSelected] = useState<Doc | null>(null)
const [releases, setReleases] = useState<Release[]>([]) const [releases, setReleases] = useState<Release[]>([])
const [dragging, setDragging] = useState(false)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [toast, setToast] = useState<string | null>(null) const [toast, setToast] = useState<string | null>(null)
const [tab, setTab] = useState<'documents' | 'uploads'>('documents') const [tab, setTab] = useState<'documents' | 'uploads'>('documents')
const [investorUploads, setInvestorUploads] = useState<Record<string, InvestorUpload[]>>({}) 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) const fileRef = useRef<HTMLInputElement>(null)
function flash(msg: string) { function flash(msg: string) { setToast(msg); setTimeout(() => setToast(null), 3000) }
setToast(msg)
setTimeout(() => setToast(null), 3000)
}
async function loadDocs() { async function loadDocs() {
const r = await fetch('/api/admin/dataroom/documents') 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]) useEffect(() => { if (tab === 'uploads' && investors.length > 0) loadInvestorUploads() }, [tab, investors])
async function selectDoc(doc: Doc) { async function selectDoc(doc: Doc) {
setSelected(doc) setSelected(doc); setDescEdit(null)
await loadReleases(doc.id) await loadReleases(doc.id)
} }
async function uploadFile(e: React.ChangeEvent<HTMLInputElement>) { async function uploadFiles(files: FileList | File[]) {
const file = e.target.files?.[0] const list = Array.from(files).filter(f => f.size > 0)
if (!file) return if (!list.length) return
setUploading(true) setUploading(true)
const fd = new FormData() 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 }) const r = await fetch('/api/admin/dataroom/documents', { method: 'POST', body: fd })
setUploading(false) 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') } 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) { async function deleteDoc(id: string) {
if (!confirm('Delete this document? All releases will be removed.')) return if (!confirm('Delete this document? All releases will be removed.')) return
setBusy(true) setBusy(true)
@@ -129,27 +150,55 @@ export default function DataroomPage() {
await fetch(`/api/admin/dataroom/documents/${selected.id}/release/${investorId}`, { method: 'DELETE' }) await fetch(`/api/admin/dataroom/documents/${selected.id}/release/${investorId}`, { method: 'DELETE' })
} else { } else {
await fetch(`/api/admin/dataroom/documents/${selected.id}/release`, { await fetch(`/api/admin/dataroom/documents/${selected.id}/release`, {
method: 'POST', method: 'POST', headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ investor_ids: [investorId] }), body: JSON.stringify({ investor_ids: [investorId] }),
}) })
} }
setBusy(false) setBusy(false); await loadReleases(selected.id); loadDocs()
await loadReleases(selected.id)
loadDocs()
} }
async function releaseAll() { async function releaseAll() {
if (!selected || investors.length === 0) return if (!selected || investors.length === 0) return
setBusy(true) setBusy(true)
await fetch(`/api/admin/dataroom/documents/${selected.id}/release`, { await fetch(`/api/admin/dataroom/documents/${selected.id}/release`, {
method: 'POST', method: 'POST', headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ investor_ids: investors.map(i => i.id) }), 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) setBusy(false)
await loadReleases(selected.id) if (r.ok) {
loadDocs() 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)) 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"> <div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-white">Data Room</h1> <h1 className="text-xl font-semibold text-white">Data Room</h1>
<div className="flex gap-2"> <div className="flex gap-2">
<button {(['documents', 'uploads'] as const).map(t => (
onClick={() => setTab('documents')} <button key={t} onClick={() => setTab(t)}
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'}`} 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'}
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
</button> </button>
))}
</div> </div>
</div> </div>
{tab === 'documents' && ( {tab === 'documents' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <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="space-y-3">
<div className="flex items-center justify-between"> <div
<span className="text-sm text-white/50">{docs.length} document{docs.length !== 1 ? 's' : ''}</span> onDragOver={e => { e.preventDefault(); setDragging(true) }}
<button onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
onClick={() => fileRef.current?.click()} onClick={() => fileRef.current?.click()}
disabled={uploading} 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]'}`}
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"
> >
<Upload className="w-4 h-4" /> {uploading
{uploading ? 'Uploading…' : 'Upload'} ? <p className="text-white/40 text-sm">Uploading</p>
</button> : <>
<input ref={fileRef} type="file" className="hidden" onChange={uploadFile} /> <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> </div>
{docs.length === 0 && ( {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"> <p className="text-xs text-white/30">{docs.length} document{docs.length !== 1 ? 's' : ''}</p>
No documents yet. Upload the first one.
</div>
)} )}
{docs.map(doc => ( {docs.map(doc => (
<button <button key={doc.id} onClick={() => selectDoc(doc)}
key={doc.id} 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]'}`}>
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"> <div className="flex items-start gap-3">
<FileText className="w-5 h-5 text-indigo-400 mt-0.5 shrink-0" /> <FileText className="w-5 h-5 text-indigo-400 mt-0.5 shrink-0" />
<div className="min-w-0 flex-1"> <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-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> <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> </div>
<span className="text-xs text-white/40 shrink-0"> <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'} {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> </div>
{/* Release panel */} {/* Detail panel */}
{selected ? ( {selected ? (
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5 space-y-4"> <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"> <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 className="text-xs text-white/40 mt-0.5">{fmt(selected.file_size)} · {selected.mime_type}</div>
</div> </div>
<div className="flex gap-2 shrink-0"> <div className="flex gap-2 shrink-0">
<button <button onClick={releaseAll} disabled={busy || allInvestors.length === 0}
onClick={releaseAll} 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">
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 <Share2 className="w-3.5 h-3.5" /> Release all
</button> </button>
<button <button onClick={() => deleteDoc(selected.id)} disabled={busy}
onClick={() => deleteDoc(selected.id)} 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">
disabled={busy} <Trash2 className="w-3.5 h-3.5" />
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> </button>
<button onClick={() => setSelected(null)} className="text-white/40 hover:text-white/80"> <button onClick={() => setSelected(null)} className="text-white/40 hover:text-white/80">
<X className="w-4 h-4" /> <X className="w-4 h-4" />
@@ -248,24 +286,63 @@ export default function DataroomPage() {
</div> </div>
</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="border-t border-white/[0.06] pt-4">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<Users className="w-4 h-4 text-white/40" /> <Users className="w-4 h-4 text-white/40" />
<span className="text-xs font-semibold text-white/60 uppercase tracking-wider">Investor Access</span> <span className="text-xs font-semibold text-white/60 uppercase tracking-wider">Investor Access</span>
</div> </div>
{allInvestors.length === 0 && ( {allInvestors.length === 0 && <p className="text-xs text-white/30">No active investors yet.</p>}
<p className="text-xs text-white/30">No active investors yet.</p>
)}
<div className="space-y-2"> <div className="space-y-2">
{allInvestors.map(inv => { {allInvestors.map(inv => {
const has = releasedIds.has(inv.id) const has = releasedIds.has(inv.id)
return ( return (
<button <button key={inv.id} onClick={() => toggleRelease(inv.id, has)} disabled={busy}
key={inv.id} className="w-full flex items-center gap-3 p-2.5 rounded-lg hover:bg-white/[0.04] transition-colors disabled:opacity-50">
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'}`}> <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" />} {has && <Check className="w-3 h-3 text-white" />}
</div> </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> <span className="ml-auto text-xs text-white/40">{uploads.length} file{uploads.length !== 1 ? 's' : ''}</span>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{uploads.map(u => ( {uploads.map(u => {
<div key={u.id} className="flex items-center gap-3 p-2.5 bg-white/[0.03] rounded-lg"> 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" /> <FileText className="w-4 h-4 text-white/40 shrink-0" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-sm text-white truncate">{u.display_name || u.filename}</div> <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 className="text-xs text-white/40">{fmt(u.file_size)} · {new Date(u.created_at).toLocaleString()}</div>
</div> </div>
<a <div className="flex items-center gap-2 shrink-0">
href={`/api/admin/dataroom/investors/${inv.id}/uploads?download=${u.id}`} <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 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" className="text-xs text-white/30 hover:text-white/60 flex items-center gap-1">
download <Pencil className="w-3 h-3" />
> </button>
<Download className="w-3.5 h-3.5" /> Download <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> </a>
</div> </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>
</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
}
}