diff --git a/backend/handlers/files.go b/backend/handlers/files.go index 9834109..22f8d46 100644 --- a/backend/handlers/files.go +++ b/backend/handlers/files.go @@ -160,6 +160,66 @@ func (h *FSHandler) getFileInfo(c *fiber.Ctx, absPath string) error { return c.JSON(fi) } +// GetExtendedFileInfo gets recursive size and file count for info modal +// GET /api/files/info/* +func (h *FSHandler) GetExtendedFileInfo(c *fiber.Ctx) error { + resolvedPath := c.Locals("resolvedPath").(string) + relativePath := c.Locals("relativePath").(string) + + info, err := os.Stat(resolvedPath) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file not found"}) + } + + fi := FileInfo{ + Name: info.Name(), + Path: filepath.ToSlash(relativePath), + IsDir: info.IsDir(), + Size: info.Size(), + MimeType: mime.TypeByExtension(filepath.Ext(info.Name())), + ModTime: info.ModTime(), + } + + var isPinned int + var isTrashed int + var checksum sql.NullString + var createdAt string + err = h.DB.QueryRow( + "SELECT is_pinned, is_trashed, checksum, created_at FROM files WHERE path = ?", + filepath.ToSlash(relativePath), + ).Scan(&isPinned, &isTrashed, &checksum, &createdAt) + + if err == nil { + fi.IsPinned = isPinned == 1 + fi.IsTrashed = isTrashed == 1 + if checksum.Valid { + fi.Checksum = checksum.String + } + } + + fileCount := 0 + if fi.IsDir { + var totalSize int64 = 0 + filepath.Walk(resolvedPath, func(p string, i os.FileInfo, err error) error { + if err != nil { + return nil + } + if !i.IsDir() { + totalSize += i.Size() + fileCount++ + } + return nil + }) + fi.Size = totalSize + } + + return c.JSON(fiber.Map{ + "info": fi, + "file_count": fileCount, + "created_at": createdAt, + }) +} + // CreateFolder creates a new directory. // POST /api/files/mkdir func (h *FSHandler) CreateFolder(c *fiber.Ctx) error { diff --git a/backend/handlers/sharing.go b/backend/handlers/sharing.go index 2d08c86..1f1f478 100644 --- a/backend/handlers/sharing.go +++ b/backend/handlers/sharing.go @@ -35,10 +35,16 @@ func (h *ShareHandler) CreateShare(c *fiber.Ctx) error { // Validate path fullPath := filepath.Join(h.Config.StorageDir, filepath.FromSlash(req.Path)) - if _, err := os.Stat(fullPath); err != nil { + info, err := os.Stat(fullPath) + if err != nil { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file not found"}) } + isDirInt := 0 + if info.IsDir() { + isDirInt = 1 + } + id := uuid.New().String() var hash *string if req.Password != "" { @@ -60,10 +66,10 @@ func (h *ShareHandler) CreateShare(c *fiber.Ctx) error { maxDownloads = &req.MaxDownloads } - _, err := h.DB.Exec(` - INSERT INTO share_links (id, file_path, password_hash, expires_at, max_downloads) - VALUES (?, ?, ?, ?, ?) - `, id, req.Path, hash, expiresAt, maxDownloads) + _, err = h.DB.Exec(` + INSERT INTO share_links (token, target_path, is_dir, password_hash, expires_at, max_downloads, download_count) + VALUES (?, ?, ?, ?, ?, ?, 0) + `, id, req.Path, isDirInt, hash, expiresAt, maxDownloads) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to create share"}) @@ -81,8 +87,8 @@ func (h *ShareHandler) CreateShare(c *fiber.Ctx) error { // GET /api/share func (h *ShareHandler) ListShares(c *fiber.Ctx) error { rows, err := h.DB.Query(` - SELECT id, file_path, expires_at, downloads, max_downloads, created_at, - (password_hash IS NOT NULL) as has_password + SELECT token, target_path, expires_at, download_count, max_downloads, created_at, + (password_hash IS NOT NULL AND password_hash != '') as has_password FROM share_links ORDER BY created_at DESC `) @@ -93,16 +99,29 @@ func (h *ShareHandler) ListShares(c *fiber.Ctx) error { var shares []map[string]interface{} for rows.Next() { - var id, path, createdAt string + var token, path, createdAt string var expiresAt *string var downloads int var maxDownloads *int var hasPassword bool - rows.Scan(&id, &path, &expiresAt, &downloads, &maxDownloads, &createdAt, &hasPassword) + rows.Scan(&token, &path, &expiresAt, &downloads, &maxDownloads, &createdAt, &hasPassword) + + // Skip expired shares + if expiresAt != nil { + t, err := time.Parse("2006-01-02 15:04:05-07:00", *expiresAt) + if err == nil && time.Now().After(t) { + // To handle different time formats, we could parse it more robustly + // but for now, we'll let frontend also check or we just check here. + } + t2, err2 := time.Parse(time.RFC3339, *expiresAt) + if err2 == nil && time.Now().After(t2) { + continue + } + } shares = append(shares, map[string]interface{}{ - "id": id, + "id": token, "path": path, "expires_at": expiresAt, "downloads": downloads, @@ -119,7 +138,7 @@ func (h *ShareHandler) ListShares(c *fiber.Ctx) error { // DELETE /api/share/:id func (h *ShareHandler) RevokeShare(c *fiber.Ctx) error { id := c.Params("id") - _, err := h.DB.Exec(`DELETE FROM share_links WHERE id = ?`, id) + _, err := h.DB.Exec(`DELETE FROM share_links WHERE token = ?`, id) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to revoke"}) } @@ -145,8 +164,8 @@ func (h *ShareHandler) GetPublicShare(c *fiber.Ctx) error { var downloads int err := h.DB.QueryRow(` - SELECT file_path, password_hash, expires_at, max_downloads, downloads - FROM share_links WHERE id = ? + SELECT target_path, password_hash, expires_at, max_downloads, download_count + FROM share_links WHERE token = ? `, id).Scan(&path, &hash, &expiresAt, &maxDownloads, &downloads) if err != nil { @@ -204,8 +223,8 @@ func (h *ShareHandler) DownloadPublicShare(c *fiber.Ctx) error { var downloads int err := h.DB.QueryRow(` - SELECT file_path, password_hash, expires_at, max_downloads, downloads - FROM share_links WHERE id = ? + SELECT target_path, password_hash, expires_at, max_downloads, download_count + FROM share_links WHERE token = ? `, id).Scan(&path, &hash, &expiresAt, &maxDownloads, &downloads) if err != nil { @@ -229,7 +248,7 @@ func (h *ShareHandler) DownloadPublicShare(c *fiber.Ctx) error { fullPath := filepath.Join(h.Config.StorageDir, filepath.FromSlash(path)) // Increment download counter - h.DB.Exec(`UPDATE share_links SET downloads = downloads + 1 WHERE id = ?`, id) + h.DB.Exec(`UPDATE share_links SET download_count = download_count + 1 WHERE token = ?`, id) h.DB.AddAuditLog("share_downloaded", fmt.Sprintf("Downloaded share %s", id), c.IP()) return c.SendFile(fullPath) diff --git a/backend/main.go b/backend/main.go index e32ebe7..3960a61 100644 --- a/backend/main.go +++ b/backend/main.go @@ -167,6 +167,7 @@ func main() { // File listing and download (with path jail) files := protected.Group("/files", middleware.PathJail(cfg.StorageDir)) + files.Get("/info/*", fsHandler.GetExtendedFileInfo) files.Get("/thumbnail/:checksum", fsHandler.ServeThumbnail) files.Get("/download/*", fsHandler.Download) files.Post("/zip", archiveHandler.Zip) diff --git a/frontend/src/components/FileExplorer.tsx b/frontend/src/components/FileExplorer.tsx index 3ae5358..5b3d4e7 100644 --- a/frontend/src/components/FileExplorer.tsx +++ b/frontend/src/components/FileExplorer.tsx @@ -15,6 +15,8 @@ import SettingsPage from './SettingsPage'; import StorageDashboard from './StorageDashboard'; import TaskManager from './TaskManager'; import ShareModal from './ShareModal'; +import FileInfoModal from './FileInfoModal'; +import SharedFilesPage from './SharedFilesPage'; interface FileItem { name: string; @@ -34,7 +36,7 @@ interface FileExplorerProps { } type ViewMode = 'grid' | 'list'; -type SidebarSection = 'files' | 'pinned' | 'trash' | 'storage' | 'settings'; +type SidebarSection = 'files' | 'trash' | 'storage' | 'settings' | 'shared'; interface UploadProgress { id: string; @@ -80,7 +82,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { const [newFolderName, setNewFolderName] = useState(''); const [dragOver, setDragOver] = useState(false); const [previewFile, setPreviewFile] = useState(null); - const [showInfo, setShowInfo] = useState(false); + const [infoFile, setInfoFile] = useState(null); const [sharingFile, setSharingFile] = useState(null); const [sortBy, setSortBy] = useState<'name' | 'size' | 'date' | 'type'>('name'); const [sortAsc, setSortAsc] = useState(true); @@ -92,6 +94,8 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { const [moveModalPath, setMoveModalPath] = useState('.'); const [moveFolders, setMoveFolders] = useState([]); const [dragOverFolder, setDragOverFolder] = useState(null); + const [pinnedExpanded, setPinnedExpanded] = useState(false); + const [pinnedOrder, setPinnedOrder] = useState([]); const searchTimeoutRef = useRef(undefined); const fileInputRef = useRef(null); @@ -105,6 +109,11 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { if (s) { if (s.grid_size) setGridSize(s.grid_size); if (s.trash_auto_purge_days) setTrashAutoPurgeDays(parseInt(s.trash_auto_purge_days, 10)); + if (s.pinned_order) { + try { + setPinnedOrder(JSON.parse(s.pinned_order)); + } catch(e) {} + } const fetchedTheme = s.theme || 'dark'; setTheme(fetchedTheme); if (fetchedTheme === 'light') { @@ -249,13 +258,55 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { if (section === 'files') { navigateTo('.'); loadFiles(); - } else if (section === 'pinned') { - loadPinned(); } else if (section === 'trash') { loadTrash(); } } + // Effect to keep pinned list loaded for the sidebar + const [pinnedFiles, setPinnedFiles] = useState([]); + useEffect(() => { + if (pinnedExpanded) { + api.listPinned().then(data => { + // Sort by pinnedOrder + const items = data.items || []; + items.sort((a: FileItem, b: FileItem) => { + const idxA = pinnedOrder.indexOf(a.path); + const idxB = pinnedOrder.indexOf(b.path); + if (idxA === -1 && idxB === -1) return a.name.localeCompare(b.name); + if (idxA === -1) return 1; + if (idxB === -1) return -1; + return idxA - idxB; + }); + setPinnedFiles(items); + }).catch(console.error); + } + }, [pinnedExpanded, pinnedOrder]); + + async function handlePinnedDragEnd(sourcePath: string, destPath: string) { + const newOrder = [...pinnedOrder]; + // Ensure all current pinned files are in order array + const currentPaths = pinnedFiles.map(f => f.path); + for (const p of currentPaths) { + if (!newOrder.includes(p)) newOrder.push(p); + } + + const srcIdx = newOrder.indexOf(sourcePath); + const destIdx = newOrder.indexOf(destPath); + if (srcIdx > -1 && destIdx > -1) { + newOrder.splice(srcIdx, 1); + newOrder.splice(destIdx, 0, sourcePath); + setPinnedOrder(newOrder); + try { + const data = await api.getSettings(); + const s = data?.settings || data || {}; + await api.updateSettings({ ...s, pinned_order: JSON.stringify(newOrder) }); + } catch (err) { + console.error('Failed to save pinned order', err); + } + } + } + function navigateUp() { if (currentPath === '.' || currentPath === '') return; const parts = currentPath.split('/'); @@ -328,6 +379,20 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { async function handlePin(file: FileItem) { await api.togglePin(file.path); loadFiles(); + if (pinnedExpanded) { + api.listPinned().then(data => { + const items = data.items || []; + items.sort((a: FileItem, b: FileItem) => { + const idxA = pinnedOrder.indexOf(a.path); + const idxB = pinnedOrder.indexOf(b.path); + if (idxA === -1 && idxB === -1) return a.name.localeCompare(b.name); + if (idxA === -1) return 1; + if (idxB === -1) return -1; + return idxA - idxB; + }); + setPinnedFiles(items); + }).catch(console.error); + } } async function handleRename(path: string) { @@ -559,10 +624,9 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { }); const groupedFiles = useMemo(() => { - const baseFiles = displayFiles.filter(f => activeSection !== 'files' || !f.is_pinned); + const baseFiles = displayFiles; if (activeSection !== 'trash' || trashAutoPurgeDays <= 0) { - const defaultName = activeSection === 'files' && displayFiles.some(f => f.is_pinned) ? 'All Files' : ''; - return [{ name: defaultName, files: baseFiles }]; + return [{ name: '', files: baseFiles }]; } const now = new Date(); @@ -638,10 +702,81 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { {/* Navigation */} -