Fix pinned icons, add bulk download, add media metadata
Some checks failed
Automated Container Build / build-and-push (push) Has been cancelled
Some checks failed
Automated Container Build / build-and-push (push) Has been cancelled
This commit is contained in:
parent
2b49e90b81
commit
ed5cc79c25
6 changed files with 182 additions and 18 deletions
|
|
@ -19,6 +19,7 @@ FROM alpine:3.21
|
||||||
|
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
|
exiftool \
|
||||||
sqlite \
|
sqlite \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
mailcap \
|
mailcap \
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,10 @@ type FileInfo struct {
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
MimeType string `json:"mime_type"`
|
MimeType string `json:"mime_type"`
|
||||||
IsPinned bool `json:"is_pinned"`
|
IsPinned bool `json:"is_pinned"`
|
||||||
IsTrashed bool `json:"is_trashed"`
|
IsTrashed bool `json:"is_trashed"`
|
||||||
ModTime time.Time `json:"mod_time"`
|
ModTime time.Time `json:"mod_time"`
|
||||||
Checksum string `json:"checksum,omitempty"`
|
Checksum string `json:"checksum,omitempty"`
|
||||||
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FolderNode struct {
|
type FolderNode struct {
|
||||||
|
|
@ -280,10 +281,28 @@ func (h *FSHandler) GetExtendedFileInfo(c *fiber.Ctx) error {
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
fi.Size = totalSize
|
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{
|
return c.JSON(fiber.Map{
|
||||||
"info": fi,
|
"info": fi,
|
||||||
"file_count": fileCount,
|
"file_count": fileCount,
|
||||||
"created_at": createdAt,
|
"created_at": createdAt,
|
||||||
})
|
})
|
||||||
|
|
@ -679,6 +698,90 @@ func (h *FSHandler) DownloadFolder(c *fiber.Ctx) error {
|
||||||
return nil
|
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.
|
// TogglePin pins or unpins a file/folder.
|
||||||
// POST /api/files/pin
|
// POST /api/files/pin
|
||||||
func (h *FSHandler) TogglePin(c *fiber.Ctx) error {
|
func (h *FSHandler) TogglePin(c *fiber.Ctx) error {
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,7 @@ func main() {
|
||||||
protected.Post("/files/move", fsHandler.Move)
|
protected.Post("/files/move", fsHandler.Move)
|
||||||
protected.Post("/files/copy", fsHandler.Copy)
|
protected.Post("/files/copy", fsHandler.Copy)
|
||||||
protected.Get("/files/download-folder", fsHandler.DownloadFolder)
|
protected.Get("/files/download-folder", fsHandler.DownloadFolder)
|
||||||
|
protected.Post("/files/download-bulk", fsHandler.DownloadBulk)
|
||||||
protected.Post("/files/upload", fsHandler.Upload)
|
protected.Post("/files/upload", fsHandler.Upload)
|
||||||
|
|
||||||
protected.Get("/files/folders-tree", fsHandler.GetFolderTree)
|
protected.Get("/files/folders-tree", fsHandler.GetFolderTree)
|
||||||
|
|
|
||||||
|
|
@ -788,7 +788,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
|
|
||||||
if (ext === 'pdf') {
|
if (ext === 'pdf') {
|
||||||
return (
|
return (
|
||||||
<div className={`${large ? 'w-20 h-20 rounded-2xl text-2xl' : 'w-6 h-6 rounded text-[8px]'} flex items-center justify-center font-black tracking-tighter shadow-sm text-white bg-[#ea4335]`}>
|
<div className={`${large ? 'w-20 h-20 rounded-2xl text-2xl' : 'w-5 h-5 rounded text-[6px]'} flex items-center justify-center font-black tracking-tighter shadow-sm text-white bg-[#ea4335]`}>
|
||||||
PDF
|
PDF
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -797,40 +797,40 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
const fallbackIcon = (() => {
|
const fallbackIcon = (() => {
|
||||||
if (isImage) {
|
if (isImage) {
|
||||||
return (
|
return (
|
||||||
<div className={`${large ? 'w-20 h-20 rounded-2xl' : 'w-6 h-6 rounded'} flex items-center justify-center shadow-sm text-white bg-[#3b82f6]`}>
|
<div className={`${large ? 'w-20 h-20 rounded-2xl' : 'w-5 h-5 rounded'} flex items-center justify-center shadow-sm text-white bg-[#3b82f6]`}>
|
||||||
<ImageIcon className={large ? "w-10 h-10 text-white" : "w-3.5 h-3.5 text-white"} />
|
<ImageIcon className={large ? "w-10 h-10 text-white" : "w-3 h-3 text-white"} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
return (
|
return (
|
||||||
<div className={`${large ? 'w-20 h-20 rounded-2xl' : 'w-6 h-6 rounded'} flex items-center justify-center shadow-sm text-white bg-[#a855f7]`}>
|
<div className={`${large ? 'w-20 h-20 rounded-2xl' : 'w-5 h-5 rounded'} flex items-center justify-center shadow-sm text-white bg-[#a855f7]`}>
|
||||||
<FilmIcon className={large ? "w-10 h-10 text-white" : "w-3.5 h-3.5 text-white"} />
|
<FilmIcon className={large ? "w-10 h-10 text-white" : "w-3 h-3 text-white"} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (['mp3', 'wav', 'flac', 'ogg', 'm4a'].includes(ext)) {
|
if (['mp3', 'wav', 'flac', 'ogg', 'm4a'].includes(ext)) {
|
||||||
return (
|
return (
|
||||||
<div className={`${large ? 'w-20 h-20 rounded-2xl' : 'w-6 h-6 rounded'} flex items-center justify-center shadow-sm text-white bg-[#ec4899]`}>
|
<div className={`${large ? 'w-20 h-20 rounded-2xl' : 'w-5 h-5 rounded'} flex items-center justify-center shadow-sm text-white bg-[#ec4899]`}>
|
||||||
<MusicIcon className={large ? "w-10 h-10 text-white" : "w-3.5 h-3.5 text-white"} />
|
<MusicIcon className={large ? "w-10 h-10 text-white" : "w-3 h-3 text-white"} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (['js', 'ts', 'jsx', 'tsx', 'py', 'go', 'rs', 'java', 'c', 'cpp', 'html', 'css', 'json'].includes(ext)) {
|
if (['js', 'ts', 'jsx', 'tsx', 'py', 'go', 'rs', 'java', 'c', 'cpp', 'html', 'css', 'json'].includes(ext)) {
|
||||||
return (
|
return (
|
||||||
<div className={`${large ? 'w-20 h-20 rounded-2xl' : 'w-6 h-6 rounded'} flex items-center justify-center shadow-sm text-white bg-[#f59e0b]`}>
|
<div className={`${large ? 'w-20 h-20 rounded-2xl' : 'w-5 h-5 rounded'} flex items-center justify-center shadow-sm text-white bg-[#f59e0b]`}>
|
||||||
<CodeIcon className={large ? "w-10 h-10 text-white" : "w-3.5 h-3.5 text-white"} />
|
<CodeIcon className={large ? "w-10 h-10 text-white" : "w-3 h-3 text-white"} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (['zip', 'tar', 'gz', 'rar', '7z'].includes(ext)) {
|
if (['zip', 'tar', 'gz', 'rar', '7z'].includes(ext)) {
|
||||||
return (
|
return (
|
||||||
<div className={`${large ? 'w-20 h-20 rounded-2xl' : 'w-6 h-6 rounded'} flex items-center justify-center shadow-sm text-white bg-[#14b8a6]`}>
|
<div className={`${large ? 'w-20 h-20 rounded-2xl' : 'w-5 h-5 rounded'} flex items-center justify-center shadow-sm text-white bg-[#14b8a6]`}>
|
||||||
<ArchiveIcon className={large ? "w-10 h-10 text-white" : "w-3.5 h-3.5 text-white"} />
|
<ArchiveIcon className={large ? "w-10 h-10 text-white" : "w-3 h-3 text-white"} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -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"
|
className="p-2 hover:bg-white/5 rounded-lg transition-colors flex items-center gap-2 text-sm"
|
||||||
style={{ color: 'var(--color-text-primary)' }}
|
style={{ color: 'var(--color-text-primary)' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// In a real app, this would trigger a bulk download zip
|
const form = document.createElement('form');
|
||||||
alert('Bulk download not implemented yet');
|
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());
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Download className="w-4 h-4" />
|
<Download className="w-4 h-4" />
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
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';
|
import api from '@/lib/api';
|
||||||
|
|
||||||
interface FileInfoModalProps {
|
interface FileInfoModalProps {
|
||||||
|
|
@ -139,6 +139,45 @@ export default function FileInfoModal({ filePath, onClose }: FileInfoModalProps)
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{data.info.metadata && Object.keys(data.info.metadata).length > 0 && (
|
||||||
|
<div className="space-y-3 bg-black/5 dark:bg-white/5 rounded-xl p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2 text-sm font-semibold" style={{ color: 'var(--color-text-primary)' }}>
|
||||||
|
<Info className="w-4 h-4" /> Media Information
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.info.metadata.ImageWidth && data.info.metadata.ImageHeight && (
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span style={{ color: 'var(--color-text-secondary)' }}>Resolution</span>
|
||||||
|
<span style={{ color: 'var(--color-text-primary)' }}>{data.info.metadata.ImageWidth} × {data.info.metadata.ImageHeight}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.info.metadata.VideoFrameRate && (
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span style={{ color: 'var(--color-text-secondary)' }}>Framerate</span>
|
||||||
|
<span style={{ color: 'var(--color-text-primary)' }}>{data.info.metadata.VideoFrameRate} fps</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.info.metadata.CompressorID && (
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span style={{ color: 'var(--color-text-secondary)' }}>Codec</span>
|
||||||
|
<span style={{ color: 'var(--color-text-primary)' }}>{data.info.metadata.CompressorID}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.info.metadata.Make && data.info.metadata.Model && (
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span style={{ color: 'var(--color-text-secondary)' }}>Camera</span>
|
||||||
|
<span style={{ color: 'var(--color-text-primary)' }}>{data.info.metadata.Make} {data.info.metadata.Model}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.info.metadata.DateTimeOriginal && (
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span style={{ color: 'var(--color-text-secondary)' }}>Captured</span>
|
||||||
|
<span style={{ color: 'var(--color-text-primary)' }}>{data.info.metadata.DateTimeOriginal}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center text-sm py-10" style={{ color: 'var(--color-text-secondary)' }}>
|
<div className="text-center text-sm py-10" style={{ color: 'var(--color-text-secondary)' }}>
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,11 @@ class ApiClient {
|
||||||
return this.accessToken ? `${url}&token=${this.accessToken}` : url;
|
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) {
|
getThumbnailUrl(checksum: string) {
|
||||||
const url = `${API_BASE}/api/files/thumbnail/${encodeURIComponent(checksum)}`;
|
const url = `${API_BASE}/api/files/thumbnail/${encodeURIComponent(checksum)}`;
|
||||||
return this.accessToken ? `${url}?token=${this.accessToken}` : url;
|
return this.accessToken ? `${url}?token=${this.accessToken}` : url;
|
||||||
|
|
|
||||||
Reference in a new issue