feat: file preview, buyer visibility, inline editing, template consolidation, CSS centralization
This commit is contained in:
parent
6d09b1ad24
commit
3caf312fdb
215
api/handlers.go
215
api/handlers.go
|
|
@ -3,9 +3,10 @@ package api
|
|||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"crypto/subtle"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/csv"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
|
@ -14,6 +15,7 @@ import (
|
|||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -21,6 +23,9 @@ import (
|
|||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"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"
|
||||
)
|
||||
|
||||
|
|
@ -838,6 +843,123 @@ func (h *Handlers) DownloadObject(w http.ResponseWriter, r *http.Request) {
|
|||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -2058,7 +2180,8 @@ func (h *Handlers) ImportRequests(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
// 1. Create request_list entry (depth=1, parent=project)
|
||||
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)
|
||||
rlSummaryPacked, _ := lib.Pack(projectKey, listName)
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -2207,6 +2387,15 @@ func (h *Handlers) ListRequestTree(w http.ResponseWriter, r *http.Request) {
|
|||
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(
|
||||
`SELECT entry_id, project_id, parent_id, type, depth, sort_order,
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
linkCounts, _ := lib.AnswerLinkCountsByProject(h.DB, projectID)
|
||||
|
||||
|
|
@ -2306,6 +2511,10 @@ func (h *Handlers) ListRequestTree(w http.ResponseWriter, r *http.Request) {
|
|||
if te == nil {
|
||||
continue
|
||||
}
|
||||
// Skip hidden request_lists and their subtrees
|
||||
if hiddenLists[te.EntryID] {
|
||||
continue
|
||||
}
|
||||
item := treeItem{
|
||||
EntryID: te.EntryID,
|
||||
ParentID: te.ParentID,
|
||||
|
|
|
|||
|
|
@ -85,9 +85,13 @@ func NewRouter(db *lib.DB, cfg *lib.Config, store lib.ObjectStore, websiteFS fs.
|
|||
// Answers (picker)
|
||||
r.Get("/projects/{projectID}/answers", h.ListAnswers)
|
||||
|
||||
// File upload/download
|
||||
// File upload/download/preview
|
||||
r.Post("/projects/{projectID}/objects", h.UploadObject)
|
||||
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
|
||||
// Organizations (platform level)
|
||||
|
|
|
|||
|
|
@ -263,6 +263,7 @@ type SectionData struct {
|
|||
type RequestListData struct {
|
||||
Name string `json:"name"`
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -103,6 +103,12 @@
|
|||
<option value="replace">Replace all request lists</option>
|
||||
</select>
|
||||
</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 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>
|
||||
|
|
@ -277,12 +283,18 @@
|
|||
if (item.type === 'request_list') {
|
||||
const name = d.name || 'Request List';
|
||||
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}">
|
||||
<td></td>
|
||||
<td colspan="12" style="padding-left:${indent+10}px">
|
||||
${chevron}
|
||||
<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>
|
||||
<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">⚙</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
} else if (item.type === 'section') {
|
||||
|
|
@ -580,9 +592,14 @@
|
|||
list.innerHTML = answers.map(a => {
|
||||
const d = a.data || {};
|
||||
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">
|
||||
<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>`;
|
||||
}).join('');
|
||||
} catch(e) {
|
||||
|
|
@ -719,11 +736,26 @@
|
|||
}
|
||||
|
||||
// ---- Import ----
|
||||
function openImportModal() {
|
||||
async function openImportModal() {
|
||||
document.getElementById('importModal').style.display='flex';
|
||||
document.getElementById('importFile').value='';
|
||||
document.getElementById('importListName').value='';
|
||||
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'; }
|
||||
|
||||
|
|
@ -737,6 +769,7 @@
|
|||
fd.append('file', file);
|
||||
fd.append('mode', document.getElementById('importMode').value);
|
||||
fd.append('list_name', document.getElementById('importListName').value);
|
||||
fd.append('visibility_org_id', document.getElementById('importVisibility').value);
|
||||
try {
|
||||
const res = await fetch('/api/projects/' + projectID + '/requests/import', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token }, body: fd });
|
||||
const data = await res.json();
|
||||
|
|
@ -747,6 +780,62 @@
|
|||
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 ----
|
||||
loadProject();
|
||||
loadRequestTree();
|
||||
|
|
|
|||
|
|
@ -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 objectID = d.object_id || '';
|
||||
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]">
|
||||
<span class="text-xl">${icon}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
|
|
@ -256,7 +258,10 @@
|
|||
</div>
|
||||
<div class="text-[#475569] text-xs">${a.created_at ? new Date(a.created_at * 1000).toLocaleString() : ''}</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>`;
|
||||
}).join('');
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue