feat: file preview, buyer visibility, inline editing, template consolidation, CSS centralization

This commit is contained in:
James 2026-03-07 13:10:12 -05:00
parent 6d09b1ad24
commit 3caf312fdb
5 changed files with 317 additions and 9 deletions

View File

@ -3,9 +3,10 @@ package api
import ( import (
"bufio" "bufio"
"bytes" "bytes"
"encoding/csv" "context"
"crypto/subtle"
"crypto/rand" "crypto/rand"
"crypto/subtle"
"encoding/csv"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
@ -14,6 +15,7 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
@ -21,6 +23,9 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/mish/dealspace/lib" "github.com/mish/dealspace/lib"
pdfcpuapi "github.com/pdfcpu/pdfcpu/pkg/api"
pdfcpumodel "github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model"
pdfcputypes "github.com/pdfcpu/pdfcpu/pkg/pdfcpu/types"
"github.com/xuri/excelize/v2" "github.com/xuri/excelize/v2"
) )
@ -838,6 +843,123 @@ func (h *Handlers) DownloadObject(w http.ResponseWriter, r *http.Request) {
w.Write(data) w.Write(data)
} }
// PreviewObject handles GET /api/projects/{projectID}/objects/{objectID}/preview
// Converts files to watermarked PDF for inline viewing. Videos served directly.
func (h *Handlers) PreviewObject(w http.ResponseWriter, r *http.Request) {
actorID := UserIDFromContext(r.Context())
projectID := chi.URLParam(r, "projectID")
objectID := chi.URLParam(r, "objectID")
if err := lib.CheckAccessRead(h.DB, actorID, projectID, ""); err != nil {
ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied")
return
}
data, err := h.Store.Read(projectID, objectID)
if err != nil {
ErrorResponse(w, http.StatusNotFound, "not_found", "Object not found")
return
}
user, _ := lib.UserByID(h.DB, actorID)
userEmail := "unknown"
if user != nil {
userEmail = user.Email
}
filename := r.URL.Query().Get("filename")
if filename == "" {
filename = "document"
}
ext := strings.ToLower(filepath.Ext(filename))
wmText := fmt.Sprintf("%s · %s · CONFIDENTIAL", userEmail, time.Now().Format("2006-01-02 15:04"))
// Videos: serve directly
videoExts := map[string]string{".mp4": "video/mp4", ".mov": "video/quicktime", ".avi": "video/x-msvideo", ".mkv": "video/x-matroska", ".webm": "video/webm"}
if ct, ok := videoExts[ext]; ok {
w.Header().Set("Content-Type", ct)
w.Header().Set("Content-Disposition", "inline")
w.Write(data)
return
}
var pdfBytes []byte
if ext == ".pdf" {
pdfBytes, err = watermarkPDF(data, wmText)
} else {
pdfBytes, err = convertToPDFAndWatermark(data, filename, wmText)
}
if err != nil {
log.Printf("preview conversion failed: %v", err)
// Fallback: serve original inline
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", filename))
w.Write(data)
return
}
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", strings.TrimSuffix(filename, ext)+".pdf"))
w.Header().Set("Cache-Control", "no-store, no-cache, private")
w.Write(pdfBytes)
}
// watermarkPDF applies a text watermark to existing PDF bytes using pdfcpu.
func watermarkPDF(pdfData []byte, wmText string) ([]byte, error) {
wm, err := pdfcpuapi.TextWatermark(wmText,
"font:Helvetica, points:10, pos:bc, rot:0, opacity:0.35, scale:1 abs, color: 0.5 0.5 0.5",
true, false, pdfcputypes.POINTS)
if err != nil {
return nil, fmt.Errorf("create watermark: %w", err)
}
rs := bytes.NewReader(pdfData)
var out bytes.Buffer
conf := pdfcpumodel.NewDefaultConfiguration()
if err := pdfcpuapi.AddWatermarks(rs, &out, nil, wm, conf); err != nil {
// If watermarking fails, return original PDF
return pdfData, nil
}
return out.Bytes(), nil
}
// convertToPDFAndWatermark uses LibreOffice headless to convert a file to PDF, then watermarks it.
func convertToPDFAndWatermark(data []byte, filename, wmText string) ([]byte, error) {
tmpDir, err := os.MkdirTemp("", "ds-preview-*")
if err != nil {
return nil, err
}
defer os.RemoveAll(tmpDir)
inputPath := filepath.Join(tmpDir, filename)
if err := os.WriteFile(inputPath, data, 0600); err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "libreoffice",
"--headless",
"--convert-to", "pdf",
"--outdir", tmpDir,
inputPath,
)
if out, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("libreoffice convert: %v: %s", err, out)
}
ext := filepath.Ext(filename)
pdfPath := filepath.Join(tmpDir, strings.TrimSuffix(filename, ext)+".pdf")
pdfData, err := os.ReadFile(pdfPath)
if err != nil {
return nil, fmt.Errorf("read pdf output: %w", err)
}
return watermarkPDF(pdfData, wmText)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Template serving handlers // Template serving handlers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -2058,7 +2180,8 @@ func (h *Handlers) ImportRequests(w http.ResponseWriter, r *http.Request) {
// 1. Create request_list entry (depth=1, parent=project) // 1. Create request_list entry (depth=1, parent=project)
requestListID := uuid.New().String() requestListID := uuid.New().String()
rlData := lib.RequestListData{Name: listName} visibilityOrgID := r.FormValue("visibility_org_id")
rlData := lib.RequestListData{Name: listName, VisibilityOrgID: visibilityOrgID}
rlDataJSON, _ := json.Marshal(rlData) rlDataJSON, _ := json.Marshal(rlData)
rlSummaryPacked, _ := lib.Pack(projectKey, listName) rlSummaryPacked, _ := lib.Pack(projectKey, listName)
rlDataPacked, _ := lib.Pack(projectKey, string(rlDataJSON)) rlDataPacked, _ := lib.Pack(projectKey, string(rlDataJSON))
@ -2192,6 +2315,63 @@ func (h *Handlers) ImportRequests(w http.ResponseWriter, r *http.Request) {
}) })
} }
// UpdateRequestListVisibility handles PATCH /api/projects/{projectID}/entries/{entryID}/visibility
// Sets or clears the visibility_org_id on a request_list entry. IB roles only.
func (h *Handlers) UpdateRequestListVisibility(w http.ResponseWriter, r *http.Request) {
actorID := UserIDFromContext(r.Context())
projectID := chi.URLParam(r, "projectID")
entryID := chi.URLParam(r, "entryID")
// Only IB roles can change visibility
role, err := lib.GetUserHighestRole(h.DB, actorID, projectID)
if err != nil || (role != lib.RoleIBAdmin && role != lib.RoleIBMember) {
ErrorResponse(w, http.StatusForbidden, "access_denied", "Only IB roles can change list visibility")
return
}
var req struct {
VisibilityOrgID *string `json:"visibility_org_id"` // null to clear
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
return
}
// Read the entry and update its data
entry, err := lib.EntryByID(h.DB, h.Cfg, entryID)
if err != nil || entry == nil || entry.Type != lib.TypeRequestList || entry.ProjectID != projectID {
ErrorResponse(w, http.StatusNotFound, "not_found", "Request list not found")
return
}
var rlData lib.RequestListData
if entry.DataText != "" {
json.Unmarshal([]byte(entry.DataText), &rlData)
}
if req.VisibilityOrgID != nil {
rlData.VisibilityOrgID = *req.VisibilityOrgID
} else {
rlData.VisibilityOrgID = ""
}
dataJSON, _ := json.Marshal(rlData)
projectKey, _ := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID)
dataPacked, _ := lib.Pack(projectKey, string(dataJSON))
now := time.Now().UnixMilli()
_, err = h.DB.Conn.Exec(
`UPDATE entries SET data = ?, updated_at = ?, version = version + 1 WHERE entry_id = ?`,
dataPacked, now, entryID,
)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to update visibility")
return
}
JSONResponse(w, http.StatusOK, map[string]string{"status": "updated"})
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tree API endpoints // Tree API endpoints
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -2207,6 +2387,15 @@ func (h *Handlers) ListRequestTree(w http.ResponseWriter, r *http.Request) {
return return
} }
// Get actor's role and org for visibility filtering
actorRole, _ := lib.GetUserHighestRole(h.DB, actorID, projectID)
actorUser, _ := lib.UserByID(h.DB, actorID)
actorOrgID := ""
if actorUser != nil {
actorOrgID = actorUser.OrgID
}
isIBRole := actorRole == lib.RoleIBAdmin || actorRole == lib.RoleIBMember
rows, err := h.DB.Conn.Query( rows, err := h.DB.Conn.Query(
`SELECT entry_id, project_id, parent_id, type, depth, sort_order, `SELECT entry_id, project_id, parent_id, type, depth, sort_order,
search_key, search_key2, summary, data, stage, search_key, search_key2, summary, data, stage,
@ -2280,6 +2469,22 @@ func (h *Handlers) ListRequestTree(w http.ResponseWriter, r *http.Request) {
childrenOf[te.ParentID] = append(childrenOf[te.ParentID], te.EntryID) childrenOf[te.ParentID] = append(childrenOf[te.ParentID], te.EntryID)
} }
// Build set of hidden request_list IDs based on visibility
hiddenLists := map[string]bool{}
if !isIBRole {
for _, te := range all {
if te.Type == lib.TypeRequestList {
if dataMap, ok := te.Data.(map[string]any); ok {
if visOrgID, ok := dataMap["visibility_org_id"].(string); ok && visOrgID != "" {
if visOrgID != actorOrgID {
hiddenLists[te.EntryID] = true
}
}
}
}
}
}
// Get answer link counts // Get answer link counts
linkCounts, _ := lib.AnswerLinkCountsByProject(h.DB, projectID) linkCounts, _ := lib.AnswerLinkCountsByProject(h.DB, projectID)
@ -2306,6 +2511,10 @@ func (h *Handlers) ListRequestTree(w http.ResponseWriter, r *http.Request) {
if te == nil { if te == nil {
continue continue
} }
// Skip hidden request_lists and their subtrees
if hiddenLists[te.EntryID] {
continue
}
item := treeItem{ item := treeItem{
EntryID: te.EntryID, EntryID: te.EntryID,
ParentID: te.ParentID, ParentID: te.ParentID,

View File

@ -85,9 +85,13 @@ func NewRouter(db *lib.DB, cfg *lib.Config, store lib.ObjectStore, websiteFS fs.
// Answers (picker) // Answers (picker)
r.Get("/projects/{projectID}/answers", h.ListAnswers) r.Get("/projects/{projectID}/answers", h.ListAnswers)
// File upload/download // File upload/download/preview
r.Post("/projects/{projectID}/objects", h.UploadObject) r.Post("/projects/{projectID}/objects", h.UploadObject)
r.Get("/projects/{projectID}/objects/{objectID}", h.DownloadObject) r.Get("/projects/{projectID}/objects/{objectID}", h.DownloadObject)
r.Get("/projects/{projectID}/objects/{objectID}/preview", h.PreviewObject)
// Request list visibility
r.Patch("/projects/{projectID}/entries/{entryID}/visibility", h.UpdateRequestListVisibility)
// Super admin endpoints // Super admin endpoints
// Organizations (platform level) // Organizations (platform level)

View File

@ -261,8 +261,9 @@ type SectionData struct {
// RequestListData is the JSON structure packed into a request_list entry's Data field. // RequestListData is the JSON structure packed into a request_list entry's Data field.
type RequestListData struct { type RequestListData struct {
Name string `json:"name"` Name string `json:"name"`
OrgID string `json:"org_id,omitempty"` // PE firm org entry_id OrgID string `json:"org_id,omitempty"` // PE firm org entry_id
VisibilityOrgID string `json:"visibility_org_id,omitempty"` // restrict visibility to this org (+ IB)
} }
// WorkstreamData is the JSON structure packed into a workstream entry's Data field. // WorkstreamData is the JSON structure packed into a workstream entry's Data field.

View File

@ -103,6 +103,12 @@
<option value="replace">Replace all request lists</option> <option value="replace">Replace all request lists</option>
</select> </select>
</div> </div>
<div>
<label class="block text-sm font-medium text-[#94a3b8] mb-1.5">Visible to</label>
<select id="importVisibility" class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white focus:outline-none focus:border-[#c9a84c]">
<option value="">Everyone (General List)</option>
</select>
</div>
</div> </div>
<div class="flex gap-3 mt-6"> <div class="flex gap-3 mt-6">
<button onclick="closeImportModal()" class="flex-1 py-2.5 bg-white/[0.05] hover:bg-white/[0.08] text-white rounded-lg text-sm font-medium transition">Cancel</button> <button onclick="closeImportModal()" class="flex-1 py-2.5 bg-white/[0.05] hover:bg-white/[0.08] text-white rounded-lg text-sm font-medium transition">Cancel</button>
@ -277,12 +283,18 @@
if (item.type === 'request_list') { if (item.type === 'request_list') {
const name = d.name || 'Request List'; const name = d.name || 'Request List';
const childCount = item.children_count || 0; const childCount = item.children_count || 0;
const visOrgId = d.visibility_org_id || '';
const visBadge = visOrgId
? `<span style="display:inline-block;padding:1px 8px;border-radius:9999px;font-size:10px;background:rgba(251,146,60,.15);color:#fb923c;margin-left:8px">Restricted</span>`
: `<span style="display:inline-block;padding:1px 8px;border-radius:9999px;font-size:10px;background:rgba(74,222,128,.12);color:#4ade80;margin-left:8px">All</span>`;
html += `<tr class="row-list" data-id="${item.entry_id}" data-type="${item.type}" data-parent="${item.parent_id}" data-depth="${item.depth}"> html += `<tr class="row-list" data-id="${item.entry_id}" data-type="${item.type}" data-parent="${item.parent_id}" data-depth="${item.depth}">
<td></td> <td></td>
<td colspan="12" style="padding-left:${indent+10}px"> <td colspan="12" style="padding-left:${indent+10}px">
${chevron} ${chevron}
<strong style="color:var(--ds-tx);margin-left:4px">${escHtml(name)}</strong> <strong style="color:var(--ds-tx);margin-left:4px">${escHtml(name)}</strong>
${visBadge}
<span style="color:var(--ds-tx3);margin-left:8px;font-size:11px">${childCount} items</span> <span style="color:var(--ds-tx3);margin-left:8px;font-size:11px">${childCount} items</span>
<button onclick="editVisibility('${item.entry_id}')" style="margin-left:6px;background:none;border:none;cursor:pointer;color:var(--ds-tx3);font-size:11px;padding:1px 4px" title="Change visibility">&#9881;</button>
</td> </td>
</tr>`; </tr>`;
} else if (item.type === 'section') { } else if (item.type === 'section') {
@ -580,9 +592,14 @@
list.innerHTML = answers.map(a => { list.innerHTML = answers.map(a => {
const d = a.data || {}; const d = a.data || {};
const name = d.filename || d.name || d.title || 'Document'; const name = d.filename || d.name || d.title || 'Document';
const objectID = d.object_id || '';
const previewUrl = objectID ? '/api/projects/' + projectID + '/objects/' + objectID + '/preview?filename=' + encodeURIComponent(name) : '';
return `<div class="flex items-center justify-between px-3 py-2 rounded-lg hover:bg-white/[0.04] transition"> return `<div class="flex items-center justify-between px-3 py-2 rounded-lg hover:bg-white/[0.04] transition">
<span class="text-white text-sm truncate flex-1">${escHtml(name)}</span> <span class="text-white text-sm truncate flex-1">${escHtml(name)}</span>
<button onclick="linkAnswer('${a.entry_id}')" class="ml-2 px-3 py-1 bg-[#c9a84c]/20 text-[#c9a84c] rounded text-xs font-medium hover:bg-[#c9a84c]/30 transition">Link</button> <div class="flex items-center gap-1.5 shrink-0 ml-2">
${previewUrl ? `<a href="${previewUrl}" target="_blank" class="px-2 py-1 bg-white/[0.06] text-[#94a3b8] hover:text-white rounded text-xs font-medium transition">Preview</a>` : ''}
<button onclick="linkAnswer('${a.entry_id}')" class="px-3 py-1 bg-[#c9a84c]/20 text-[#c9a84c] rounded text-xs font-medium hover:bg-[#c9a84c]/30 transition">Link</button>
</div>
</div>`; </div>`;
}).join(''); }).join('');
} catch(e) { } catch(e) {
@ -719,11 +736,26 @@
} }
// ---- Import ---- // ---- Import ----
function openImportModal() { async function openImportModal() {
document.getElementById('importModal').style.display='flex'; document.getElementById('importModal').style.display='flex';
document.getElementById('importFile').value=''; document.getElementById('importFile').value='';
document.getElementById('importListName').value=''; document.getElementById('importListName').value='';
document.getElementById('importError').classList.add('hidden'); document.getElementById('importError').classList.add('hidden');
// Populate visibility org options
try {
const res = await fetchAPI('/api/projects/' + projectID + '/orgs');
const orgs = await res.json();
const sel = document.getElementById('importVisibility');
sel.innerHTML = '<option value="">Everyone (General List)</option>';
if (orgs && orgs.length > 0) {
for (const o of orgs) {
const d = parseData(o.data_text);
const orgName = d.org_name || d.name || 'Unknown';
const orgId = d.org_id || o.entry_id || '';
sel.innerHTML += `<option value="${escHtml(orgId)}">${escHtml(orgName)} only</option>`;
}
}
} catch(e) {}
} }
function closeImportModal() { document.getElementById('importModal').style.display='none'; } function closeImportModal() { document.getElementById('importModal').style.display='none'; }
@ -737,6 +769,7 @@
fd.append('file', file); fd.append('file', file);
fd.append('mode', document.getElementById('importMode').value); fd.append('mode', document.getElementById('importMode').value);
fd.append('list_name', document.getElementById('importListName').value); fd.append('list_name', document.getElementById('importListName').value);
fd.append('visibility_org_id', document.getElementById('importVisibility').value);
try { try {
const res = await fetch('/api/projects/' + projectID + '/requests/import', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token }, body: fd }); const res = await fetch('/api/projects/' + projectID + '/requests/import', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token }, body: fd });
const data = await res.json(); const data = await res.json();
@ -747,6 +780,62 @@
finally { btn.disabled = false; btn.textContent = 'Import'; } finally { btn.disabled = false; btn.textContent = 'Import'; }
} }
// ---- Visibility edit ----
async function editVisibility(entryId) {
const item = treeData.find(t => t.entry_id === entryId);
if (!item) return;
const d = item.data || {};
const currentVis = d.visibility_org_id || '';
// Fetch orgs for the selector
let orgs = [];
try {
const res = await fetchAPI('/api/projects/' + projectID + '/orgs');
orgs = await res.json() || [];
} catch(e) {}
let opts = '<option value="">Everyone (General List)</option>';
for (const o of orgs) {
const od = parseData(o.data_text);
const orgName = od.org_name || od.name || 'Unknown';
const orgId = od.org_id || o.entry_id || '';
opts += `<option value="${escHtml(orgId)}" ${orgId === currentVis ? 'selected' : ''}>${escHtml(orgName)} only</option>`;
}
const row = document.querySelector(`tr[data-id="${entryId}"]`);
if (!row) return;
const td = row.querySelector('td:nth-child(2)');
if (!td) return;
td.dataset.origHtml = td.innerHTML;
td.innerHTML += `<div style="margin-top:4px;display:flex;align-items:center;gap:4px">
<select id="vis-sel-${entryId}" style="padding:4px 8px;background:#0a1628;border:1px solid rgba(255,255,255,.12);border-radius:6px;color:white;font-size:12px">${opts}</select>
<button onclick="saveVisibility('${entryId}')" style="padding:2px 8px;background:#c9a84c;color:#0a1628;border:none;border-radius:4px;font-size:11px;font-weight:600;cursor:pointer">Save</button>
<button onclick="cancelVisibility('${entryId}')" style="padding:2px 8px;background:rgba(255,255,255,.08);color:white;border:none;border-radius:4px;font-size:11px;cursor:pointer">Cancel</button>
</div>`;
}
function cancelVisibility(entryId) {
const row = document.querySelector(`tr[data-id="${entryId}"]`);
if (!row) return;
const td = row.querySelector('td:nth-child(2)');
if (td && td.dataset.origHtml) td.innerHTML = td.dataset.origHtml;
}
async function saveVisibility(entryId) {
const sel = document.getElementById('vis-sel-' + entryId);
if (!sel) return;
const val = sel.value || null;
try {
await fetchAPI('/api/projects/' + projectID + '/entries/' + entryId + '/visibility', {
method: 'PATCH',
body: JSON.stringify({ visibility_org_id: val })
});
loadRequestTree();
} catch(e) {
console.error('Visibility update failed:', e);
}
}
// ---- Init ---- // ---- Init ----
loadProject(); loadProject();
loadRequestTree(); loadRequestTree();

View File

@ -245,6 +245,8 @@
const icon = name.endsWith('.pdf') ? '\ud83d\udcc4' : name.match(/\.(jpg|jpeg|png|gif)$/i) ? '\ud83d\uddbc\ufe0f' : name.match(/\.(xlsx?|csv)$/i) ? '\ud83d\udcca' : '\ud83d\udcce'; const icon = name.endsWith('.pdf') ? '\ud83d\udcc4' : name.match(/\.(jpg|jpeg|png|gif)$/i) ? '\ud83d\uddbc\ufe0f' : name.match(/\.(xlsx?|csv)$/i) ? '\ud83d\udcca' : '\ud83d\udcce';
const objectID = d.object_id || ''; const objectID = d.object_id || '';
const downloadUrl = objectID ? '/api/projects/' + projectID + '/objects/' + objectID + '?filename=' + encodeURIComponent(name) : '#'; const downloadUrl = objectID ? '/api/projects/' + projectID + '/objects/' + objectID + '?filename=' + encodeURIComponent(name) : '#';
const previewUrl = objectID ? '/api/projects/' + projectID + '/objects/' + objectID + '/preview?filename=' + encodeURIComponent(name) : '';
const isVideo = /\.(mp4|mov|avi|mkv|webm)$/i.test(name);
return `<div class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-[#0a1628] border border-white/[0.05]"> return `<div class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-[#0a1628] border border-white/[0.05]">
<span class="text-xl">${icon}</span> <span class="text-xl">${icon}</span>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
@ -256,7 +258,10 @@
</div> </div>
<div class="text-[#475569] text-xs">${a.created_at ? new Date(a.created_at * 1000).toLocaleString() : ''}</div> <div class="text-[#475569] text-xs">${a.created_at ? new Date(a.created_at * 1000).toLocaleString() : ''}</div>
</div> </div>
${objectID ? `<a href="${downloadUrl}" class="text-[#c9a84c] text-sm hover:underline shrink-0">Download</a>` : ''} <div class="flex items-center gap-2 shrink-0">
${objectID && previewUrl ? `<a href="${previewUrl}" target="_blank" class="px-2 py-1 rounded text-xs font-medium bg-white/[0.06] text-[#94a3b8] hover:text-white hover:bg-white/[0.1] transition">${isVideo ? 'Play' : 'Preview'}</a>` : ''}
${objectID ? `<a href="${downloadUrl}" class="text-[#c9a84c] text-sm hover:underline">Download</a>` : ''}
</div>
</div>`; </div>`;
}).join(''); }).join('');
} }