From ed5cc79c25acb54dcdc15858c8bcebc5d7008f8e Mon Sep 17 00:00:00 2001 From: Elijah Date: Sat, 23 May 2026 08:56:58 -0700 Subject: [PATCH] Fix pinned icons, add bulk download, add media metadata --- backend/Dockerfile | 1 + backend/handlers/files.go | 111 +++++++++++++++++++++- backend/main.go | 1 + frontend/src/components/FileExplorer.tsx | 41 +++++--- frontend/src/components/FileInfoModal.tsx | 41 +++++++- frontend/src/lib/api.ts | 5 + 6 files changed, 182 insertions(+), 18 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index df9b0f8..af7fef5 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -19,6 +19,7 @@ FROM alpine:3.21 RUN apk add --no-cache \ ffmpeg \ + exiftool \ sqlite \ ca-certificates \ mailcap \ diff --git a/backend/handlers/files.go b/backend/handlers/files.go index deee6e3..2c44450 100644 --- a/backend/handlers/files.go +++ b/backend/handlers/files.go @@ -32,9 +32,10 @@ type FileInfo struct { Size int64 `json:"size"` MimeType string `json:"mime_type"` IsPinned bool `json:"is_pinned"` - IsTrashed bool `json:"is_trashed"` - ModTime time.Time `json:"mod_time"` - Checksum string `json:"checksum,omitempty"` + IsTrashed bool `json:"is_trashed"` + ModTime time.Time `json:"mod_time"` + Checksum string `json:"checksum,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` } type FolderNode struct { @@ -280,10 +281,28 @@ func (h *FSHandler) GetExtendedFileInfo(c *fiber.Ctx) error { return nil }) fi.Size = totalSize + return c.JSON(fiber.Map{ + "info": fi, + "file_count": fileCount, + "created_at": createdAt, + }) + } + + // For files, attempt to get media metadata + cat := categorizeFile(filepath.Ext(info.Name())) + if cat == "images" || cat == "videos" || cat == "audio" { + cmd := exec.Command("exiftool", "-json", resolvedPath) + output, err := cmd.Output() + if err == nil { + var meta []map[string]interface{} + if err := json.Unmarshal(output, &meta); err == nil && len(meta) > 0 { + fi.Metadata = meta[0] + } + } } return c.JSON(fiber.Map{ - "info": fi, + "info": fi, "file_count": fileCount, "created_at": createdAt, }) @@ -679,6 +698,90 @@ func (h *FSHandler) DownloadFolder(c *fiber.Ctx) error { return nil } +// DownloadBulk creates and streams a ZIP archive of multiple selected items. +// POST /api/files/download-bulk +func (h *FSHandler) DownloadBulk(c *fiber.Ctx) error { + var body struct { + Paths []string `json:"paths" form:"paths"` + } + if err := c.BodyParser(&body); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid payload"}) + } + + if len(body.Paths) == 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "no paths provided"}) + } + + c.Set("Content-Type", "application/zip") + c.Set("Content-Disposition", "attachment; filename=\"download.zip\"") + + c.Context().SetBodyStreamWriter(func(w *bufio.Writer) { + zipWriter := zip.NewWriter(w) + defer zipWriter.Close() + + for _, p := range body.Paths { + fullPath, err := h.resolveSafe(p) + if err != nil { + continue + } + + info, err := os.Stat(fullPath) + if err != nil { + continue + } + + if info.IsDir() { + filepath.Walk(fullPath, func(path string, walkInfo os.FileInfo, err error) error { + if err != nil { + return err + } + + // Make it relative to the parent of the chosen directory + relPath, err := filepath.Rel(filepath.Dir(fullPath), path) + if err != nil { + return err + } + + if walkInfo.IsDir() { + relPath += "/" + _, _ = zipWriter.Create(relPath) + return nil + } + + zipFile, err := zipWriter.Create(relPath) + if err != nil { + return err + } + + fsFile, err := os.Open(path) + if err != nil { + return err + } + defer fsFile.Close() + + _, _ = io.Copy(zipFile, fsFile) + return nil + }) + } else { + zipFile, err := zipWriter.Create(info.Name()) + if err != nil { + continue + } + + fsFile, err := os.Open(fullPath) + if err != nil { + continue + } + + _, _ = io.Copy(zipFile, fsFile) + fsFile.Close() + } + } + }) + + return nil +} + // TogglePin pins or unpins a file/folder. // POST /api/files/pin func (h *FSHandler) TogglePin(c *fiber.Ctx) error { diff --git a/backend/main.go b/backend/main.go index 50c63fd..7316c18 100644 --- a/backend/main.go +++ b/backend/main.go @@ -163,6 +163,7 @@ func main() { protected.Post("/files/move", fsHandler.Move) protected.Post("/files/copy", fsHandler.Copy) protected.Get("/files/download-folder", fsHandler.DownloadFolder) + protected.Post("/files/download-bulk", fsHandler.DownloadBulk) protected.Post("/files/upload", fsHandler.Upload) protected.Get("/files/folders-tree", fsHandler.GetFolderTree) diff --git a/frontend/src/components/FileExplorer.tsx b/frontend/src/components/FileExplorer.tsx index 047779c..dbb727b 100644 --- a/frontend/src/components/FileExplorer.tsx +++ b/frontend/src/components/FileExplorer.tsx @@ -788,7 +788,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { if (ext === 'pdf') { return ( -
+
PDF
); @@ -797,40 +797,40 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { const fallbackIcon = (() => { if (isImage) { return ( -
- +
+
); } if (isVideo) { return ( -
- +
+
); } if (['mp3', 'wav', 'flac', 'ogg', 'm4a'].includes(ext)) { return ( -
- +
+
); } if (['js', 'ts', 'jsx', 'tsx', 'py', 'go', 'rs', 'java', 'c', 'cpp', 'html', 'css', 'json'].includes(ext)) { return ( -
- +
+
); } if (['zip', 'tar', 'gz', 'rar', '7z'].includes(ext)) { return ( -
- +
+
); } @@ -1545,8 +1545,23 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { className="p-2 hover:bg-white/5 rounded-lg transition-colors flex items-center gap-2 text-sm" style={{ color: 'var(--color-text-primary)' }} onClick={() => { - // In a real app, this would trigger a bulk download zip - alert('Bulk download not implemented yet'); + const form = document.createElement('form'); + form.method = 'POST'; + form.action = api.getBulkDownloadUrl(); + form.target = '_blank'; + + Array.from(selectedFiles).forEach(path => { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = 'paths'; + input.value = path; + form.appendChild(input); + }); + + document.body.appendChild(form); + form.submit(); + document.body.removeChild(form); + setSelectedFiles(new Set()); }} > diff --git a/frontend/src/components/FileInfoModal.tsx b/frontend/src/components/FileInfoModal.tsx index 2968b7e..1259765 100644 --- a/frontend/src/components/FileInfoModal.tsx +++ b/frontend/src/components/FileInfoModal.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import { motion } from 'framer-motion'; -import { X, File, Folder, HardDrive, Calendar, RefreshCw, FileType } from 'lucide-react'; +import { X, File, Folder, HardDrive, Calendar, RefreshCw, FileType, Info } from 'lucide-react'; import api from '@/lib/api'; interface FileInfoModalProps { @@ -139,6 +139,45 @@ export default function FileInfoModal({ filePath, onClose }: FileInfoModalProps)
)}
+ + {data.info.metadata && Object.keys(data.info.metadata).length > 0 && ( +
+
+ Media Information +
+ + {data.info.metadata.ImageWidth && data.info.metadata.ImageHeight && ( +
+ Resolution + {data.info.metadata.ImageWidth} × {data.info.metadata.ImageHeight} +
+ )} + {data.info.metadata.VideoFrameRate && ( +
+ Framerate + {data.info.metadata.VideoFrameRate} fps +
+ )} + {data.info.metadata.CompressorID && ( +
+ Codec + {data.info.metadata.CompressorID} +
+ )} + {data.info.metadata.Make && data.info.metadata.Model && ( +
+ Camera + {data.info.metadata.Make} {data.info.metadata.Model} +
+ )} + {data.info.metadata.DateTimeOriginal && ( +
+ Captured + {data.info.metadata.DateTimeOriginal} +
+ )} +
+ )}
) : (
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 8cebe3c..d4c1d1c 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -237,6 +237,11 @@ class ApiClient { return this.accessToken ? `${url}&token=${this.accessToken}` : url; } + getBulkDownloadUrl() { + const url = `${API_BASE}/api/files/download-bulk`; + return this.accessToken ? `${url}?token=${this.accessToken}` : url; + } + getThumbnailUrl(checksum: string) { const url = `${API_BASE}/api/files/thumbnail/${encodeURIComponent(checksum)}`; return this.accessToken ? `${url}?token=${this.accessToken}` : url;