Shared file bug fixes, redesigned pin system, new info button
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m15s

This commit is contained in:
Elijah 2026-05-22 18:59:26 -07:00
parent 71ba029fbc
commit 2bcfca983c
7 changed files with 511 additions and 276 deletions

View file

@ -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 {

View file

@ -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)

View file

@ -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)

View file

@ -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<FileItem | null>(null);
const [showInfo, setShowInfo] = useState(false);
const [infoFile, setInfoFile] = useState<FileItem | null>(null);
const [sharingFile, setSharingFile] = useState<string | null>(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<FileItem[]>([]);
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
const [pinnedExpanded, setPinnedExpanded] = useState(false);
const [pinnedOrder, setPinnedOrder] = useState<string[]>([]);
const searchTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
const fileInputRef = useRef<HTMLInputElement>(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<FileItem[]>([]);
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) {
</div>
{/* Navigation */}
<nav className="flex-1 px-3 py-2 space-y-1">
<nav className="flex-1 px-3 py-2 space-y-1 overflow-y-auto">
{[
{ id: 'files' as SidebarSection, icon: Folder, label: 'All Files' },
{ id: 'pinned' as SidebarSection, icon: Pin, label: 'Pinned' },
].map(({ id, icon: Icon, label }) => (
<button
key={id}
id={`nav-${id}`}
onClick={() => handleSectionChange(id)}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-150"
style={{
backgroundColor: activeSection === id ? 'var(--color-accent-subtle)' : 'transparent',
color: activeSection === id ? 'var(--color-accent)' : 'var(--color-text-secondary)',
}}
>
<Icon className="w-4.5 h-4.5" />
{label}
</button>
))}
{/* Pinned Collapsible Section */}
<div className="py-1">
<button
onClick={() => setPinnedExpanded(!pinnedExpanded)}
className="w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-150 hover:bg-white/5"
style={{ color: 'var(--color-text-secondary)' }}
>
<div className="flex items-center gap-3">
<Pin className="w-4.5 h-4.5" />
Pinned
</div>
<ChevronRight className={`w-4 h-4 transition-transform duration-200 ${pinnedExpanded ? 'rotate-90' : ''}`} />
</button>
<AnimatePresence>
{pinnedExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden flex flex-col gap-0.5 mt-1"
>
{pinnedFiles.length === 0 ? (
<div className="px-10 py-2 text-xs" style={{ color: 'var(--color-text-tertiary)' }}>No pinned items</div>
) : (
pinnedFiles.map((file) => (
<div
key={file.path}
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', file.path);
}}
onDragOver={(e) => {
e.preventDefault();
}}
onDrop={(e) => {
e.preventDefault();
const source = e.dataTransfer.getData('text/plain');
if (source && source !== file.path) {
handlePinnedDragEnd(source, file.path);
}
}}
className="group flex items-center justify-between px-10 py-2 text-sm font-medium cursor-pointer rounded-lg transition-colors hover:bg-white/5"
style={{ color: 'var(--color-text-secondary)' }}
onClick={() => navigateTo(file.path)}
>
<span className="truncate flex-1">{file.name}</span>
</div>
))
)}
</motion.div>
)}
</AnimatePresence>
</div>
{[
{ id: 'shared' as SidebarSection, icon: LinkIcon, label: 'Shared Files' },
{ id: 'trash' as SidebarSection, icon: Trash2, label: 'Trash' },
{ id: 'storage' as SidebarSection, icon: HardDrive, label: 'Storage' },
].map(({ id, icon: Icon, label }) => (
@ -659,7 +794,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
{label}
</button>
))}
</nav>
{/* Settings Footer */}
@ -760,7 +894,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
</button>
)}
{(activeSection === 'files' || activeSection === 'pinned' || activeSection === 'trash') && (
{(activeSection === 'files' || activeSection === 'trash' || activeSection === 'shared') && (
<>
<button
onClick={toggleTheme}
@ -771,19 +905,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
{theme === 'dark' ? <Sun className="w-4.5 h-4.5" /> : <Moon className="w-4.5 h-4.5" />}
</button>
<button
id="info-toggle"
onClick={() => setShowInfo(!showInfo)}
className="p-2 rounded-lg transition-colors"
style={{
backgroundColor: showInfo ? 'var(--color-accent-subtle)' : 'transparent',
color: showInfo ? 'var(--color-accent)' : 'var(--color-text-secondary)',
}}
title="Toggle info panel"
>
<Info className="w-4.5 h-4.5" />
</button>
<div className="w-px h-5 mx-1" style={{ backgroundColor: 'var(--color-border-subtle)' }} />
<div className="relative group">
@ -856,6 +977,8 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
<SettingsPage onLogout={onLogout} />
) : activeSection === 'storage' ? (
<StorageDashboard />
) : activeSection === 'shared' ? (
<SharedFilesPage />
) : (
<div className="flex flex-1 overflow-hidden">
{/* File Area */}
@ -943,87 +1066,9 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
) : viewMode === 'grid' ? (
/* Grid View */
<div className="space-y-8">
{activeSection === 'files' && displayFiles.some(f => f.is_pinned) && (
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider mb-3 px-1" style={{ color: 'var(--color-text-tertiary)' }}>Pinned</h3>
<div className="grid gap-3" style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${gridSize}px, 1fr))` }}>
{displayFiles.filter(f => f.is_pinned).map((file, idx) => (
<motion.div
key={file.path}
draggable
onDragStart={(e: any) => handleItemDragStart(e, file.path)}
onDragOver={(e: any) => handleItemDragOver(e, file)}
onDragLeave={(e: any) => handleItemDragLeave(e, file)}
onDrop={(e: any) => handleItemDrop(e, file)}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: idx * 0.02 }}
onClick={(e) => handleFileClick(file, e)}
onContextMenu={(e) => handleContextMenu(e, file)}
className={`group relative p-4 rounded-xl cursor-pointer transition-all duration-150 border-2 ${draggedFile === file.path ? 'opacity-50' : ''}`}
style={{
backgroundColor: selectedFiles.has(file.path) || dragOverFolder === file.path ? 'var(--color-accent-subtle)' : 'var(--color-bg-elevated)',
border: `1px solid ${selectedFiles.has(file.path) || dragOverFolder === file.path ? 'var(--color-accent)' : 'var(--color-border-subtle)'}`,
}}
>
<div
className="absolute top-3 left-3 z-10 opacity-0 group-hover:opacity-100 transition-opacity"
style={{ opacity: selectedFiles.has(file.path) ? 1 : undefined }}
>
<input
type="checkbox"
checked={selectedFiles.has(file.path)}
onChange={(e) => {
const newSet = new Set(selectedFiles);
if (e.target.checked) newSet.add(file.path);
else newSet.delete(file.path);
setSelectedFiles(newSet);
}}
onClick={(e) => e.stopPropagation()}
className="w-4 h-4 rounded border-gray-300 cursor-pointer"
/>
</div>
{file.is_pinned && (
<Pin className="absolute top-2 right-2 w-3 h-3" style={{ color: 'var(--color-warning)' }} />
)}
<div className="flex flex-col items-center gap-3">
<div className="w-full h-32 flex items-center justify-center">
{getFileIcon(file, true)}
</div>
{renaming === file.path ? (
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(file.path); if (e.key === 'Escape') setRenaming(null); }}
onBlur={() => handleRename(file.path)}
autoFocus
onFocus={(e) => e.target.select()}
className="w-full text-xs text-center bg-transparent outline-none"
style={{
color: 'var(--color-text-primary)',
borderBottom: '1px solid var(--color-accent)',
}}
/>
) : (
<span
className="text-xs font-medium text-center w-full truncate"
style={{ color: 'var(--color-text-primary)' }}
title={file.name}
>
{file.name}
</span>
)}
</div>
</motion.div>
))}
</div>
</div>
)}
<div>
{groupedFiles.map((group, groupIdx) => (
<div key={group.name} className={groupIdx > 0 || (activeSection === 'files' && displayFiles.some(f => f.is_pinned)) ? 'mt-8' : ''}>
<div key={group.name} className={groupIdx > 0 ? 'mt-8' : ''}>
{group.name && (
<h3 className="text-xs font-semibold uppercase tracking-wider mb-3 px-1" style={{ color: 'var(--color-text-tertiary)' }}>{group.name}</h3>
)}
@ -1106,72 +1151,9 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
) : (
/* List View */
<div className="space-y-8">
{activeSection === 'files' && displayFiles.some(f => f.is_pinned) && (
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider mb-2 px-4" style={{ color: 'var(--color-text-tertiary)' }}>Pinned</h3>
<div className="space-y-0.5">
{displayFiles.filter(f => f.is_pinned).map((file, idx) => (
<motion.div
key={file.path}
draggable
onDragStart={(e: any) => handleItemDragStart(e, file.path)}
onDragOver={(e: any) => handleItemDragOver(e, file)}
onDragLeave={(e: any) => handleItemDragLeave(e, file)}
onDrop={(e: any) => handleItemDrop(e, file)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: idx * 0.015 }}
onClick={(e) => handleFileClick(file, e)}
onContextMenu={(e) => handleContextMenu(e, file)}
className={`grid grid-cols-[1fr_100px_140px_40px] gap-4 items-center px-4 py-2.5 rounded-lg cursor-pointer transition-all duration-100 group ${draggedFile === file.path ? 'opacity-50' : ''}`}
style={{ backgroundColor: selectedFiles.has(file.path) || dragOverFolder === file.path ? 'var(--color-accent-subtle)' : 'transparent' }}
>
<div className="flex items-center gap-3 min-w-0">
<input
type="checkbox"
checked={selectedFiles.has(file.path)}
onChange={(e) => {
const newSet = new Set(selectedFiles);
if (e.target.checked) newSet.add(file.path);
else newSet.delete(file.path);
setSelectedFiles(newSet);
}}
onClick={(e) => e.stopPropagation()}
className="w-4 h-4 rounded border-gray-300 cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
style={{ opacity: selectedFiles.has(file.path) ? 1 : undefined }}
/>
{getFileIcon(file)}
{renaming === file.path ? (
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(file.path); if (e.key === 'Escape') setRenaming(null); }}
onBlur={() => handleRename(file.path)}
autoFocus
onFocus={(e) => e.target.select()}
className="flex-1 text-sm bg-transparent outline-none"
style={{ color: 'var(--color-text-primary)', borderBottom: '1px solid var(--color-accent)' }}
/>
) : (
<span className="text-sm truncate" style={{ color: 'var(--color-text-primary)' }}>{file.name}</span>
)}
{file.is_pinned && <Pin className="w-3 h-3 flex-shrink-0" style={{ color: 'var(--color-warning)' }} />}
</div>
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>{file.is_dir ? '—' : formatSize(file.size)}</span>
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>{formatDate(file.mod_time)}</span>
<button onClick={(e) => { e.stopPropagation(); handleContextMenu(e, file); }} className="p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity">
<MoreVertical className="w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
</button>
</motion.div>
))}
</div>
</div>
)}
<div>
{groupedFiles.map((group, groupIdx) => (
<div key={group.name} className={groupIdx > 0 || (activeSection === 'files' && displayFiles.some(f => f.is_pinned)) ? 'mt-8' : ''}>
<div key={group.name} className={groupIdx > 0 ? 'mt-8' : ''}>
{group.name && (
<h3 className="text-xs font-semibold uppercase tracking-wider mb-2 px-4" style={{ color: 'var(--color-text-tertiary)' }}>{group.name}</h3>
)}
@ -1317,98 +1299,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
)}
</AnimatePresence>
{/* Right Info Sidebar */}
<AnimatePresence>
{showInfo && (
<motion.aside
initial={{ x: 320, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: 320, opacity: 0 }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="w-80 border-l flex-shrink-0 flex flex-col overflow-y-auto bg-opacity-50 backdrop-blur-md"
style={{
backgroundColor: 'var(--color-sidebar-bg)',
borderColor: 'var(--color-border-subtle)',
}}
>
<div className="p-5 flex flex-col gap-6">
<h3 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--color-text-tertiary)' }}>
File Details
</h3>
{selectedFiles.size === 1 ? (() => {
const file = displayFiles.find(f => f.path === Array.from(selectedFiles)[0]);
if (!file) return null;
return (
<div className="space-y-6">
<div className="flex flex-col items-center gap-3">
<div className="w-20 h-20 flex items-center justify-center">
{getFileIcon(file)}
</div>
<span className="text-sm font-medium text-center break-all" style={{ color: 'var(--color-text-primary)' }}>
{file.name}
</span>
</div>
<div className="space-y-4">
<div className="flex flex-col gap-1">
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>Type</span>
<span className="text-sm" style={{ color: 'var(--color-text-primary)' }}>
{file.is_dir ? 'Folder' : file.mime_type || 'Unknown'}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>Size</span>
<span className="text-sm" style={{ color: 'var(--color-text-primary)' }}>
{file.is_dir ? '—' : formatSize(file.size)}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>Location</span>
<span className="text-sm break-all" style={{ color: 'var(--color-text-primary)' }}>
{file.path}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>Modified</span>
<span className="text-sm" style={{ color: 'var(--color-text-primary)' }}>
{new Date(file.mod_time).toLocaleString()}
</span>
</div>
{file.checksum && (
<div className="flex flex-col gap-1">
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>Checksum (XXHash)</span>
<span className="text-xs font-mono break-all" style={{ color: 'var(--color-text-secondary)' }}>
{file.checksum}
</span>
</div>
)}
</div>
</div>
);
})() : selectedFiles.size > 1 ? (
<div className="flex flex-col items-center justify-center h-40 text-center gap-2">
<Check className="w-8 h-8" style={{ color: 'var(--color-accent)' }} />
<span className="text-sm" style={{ color: 'var(--color-text-primary)' }}>
{selectedFiles.size} items selected
</span>
</div>
) : (
<div className="flex flex-col items-center justify-center h-40 text-center gap-2">
<Info className="w-8 h-8" style={{ color: 'var(--color-text-tertiary)' }} />
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
Select a file to see its details
</span>
</div>
)}
</div>
</motion.aside>
)}
</AnimatePresence>
</div>
)}
</main>
@ -1441,6 +1331,14 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
</>
) : (
<>
<ContextMenuItem
icon={<Info className="w-4 h-4" />}
label="Info"
onClick={() => {
setInfoFile(contextMenu.file);
setContextMenu(null);
}}
/>
<ContextMenuItem
icon={<Download className="w-4 h-4" />}
label="Download"
@ -1587,6 +1485,12 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
)}
</AnimatePresence>
<AnimatePresence>
{infoFile && (
<FileInfoModal filePath={infoFile.path} onClose={() => setInfoFile(null)} />
)}
</AnimatePresence>
<AnimatePresence>
{showTrashModal && (
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/50 backdrop-blur-sm">

View file

@ -0,0 +1,119 @@
'use client';
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { X, File, Folder, HardDrive, Calendar, RefreshCw } from 'lucide-react';
import api from '@/lib/api';
interface FileInfoModalProps {
filePath: string;
onClose: () => void;
}
export default function FileInfoModal({ filePath, onClose }: FileInfoModalProps) {
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.getExtendedFileInfo(filePath).then((res) => {
setData(res);
setLoading(false);
}).catch((err) => {
console.error(err);
setLoading(false);
});
}, [filePath]);
function formatSize(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={onClose}>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
onClick={(e) => e.stopPropagation()}
className="w-full max-w-sm rounded-2xl overflow-hidden flex flex-col"
style={{ backgroundColor: 'var(--color-bg-primary)', border: '1px solid var(--color-border)' }}
>
<div className="flex items-center justify-between p-4 border-b" style={{ borderColor: 'var(--color-border-subtle)' }}>
<h2 className="text-lg font-semibold" style={{ color: 'var(--color-text-primary)' }}>File Information</h2>
<button onClick={onClose} className="p-1 rounded-lg hover:bg-white/5 transition-colors">
<X className="w-5 h-5" style={{ color: 'var(--color-text-tertiary)' }} />
</button>
</div>
<div className="p-5">
{loading ? (
<div className="flex justify-center items-center py-10">
<RefreshCw className="w-6 h-6 animate-spin" style={{ color: 'var(--color-text-tertiary)' }} />
</div>
) : data && data.info ? (
<div className="space-y-4">
<div className="flex items-center gap-4 mb-6">
<div className="w-12 h-12 rounded-xl flex items-center justify-center" style={{ backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border-subtle)' }}>
{data.info.is_dir ? (
<Folder className="w-6 h-6" style={{ color: 'var(--color-accent)' }} />
) : (
<File className="w-6 h-6" style={{ color: 'var(--color-text-secondary)' }} />
)}
</div>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold truncate" style={{ color: 'var(--color-text-primary)' }} title={data.info.name}>
{data.info.name}
</h3>
<p className="text-xs truncate" style={{ color: 'var(--color-text-secondary)' }}>
{data.info.is_dir ? 'Folder' : data.info.mime_type || 'Unknown Type'}
</p>
</div>
</div>
<div className="space-y-3 bg-black/5 dark:bg-white/5 rounded-xl p-4">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2" style={{ color: 'var(--color-text-secondary)' }}>
<HardDrive className="w-4 h-4" /> Size
</div>
<span style={{ color: 'var(--color-text-primary)' }}>{formatSize(data.info.size)}</span>
</div>
{data.info.is_dir && (
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2" style={{ color: 'var(--color-text-secondary)' }}>
<File className="w-4 h-4" /> Contains
</div>
<span style={{ color: 'var(--color-text-primary)' }}>{data.file_count} files</span>
</div>
)}
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2" style={{ color: 'var(--color-text-secondary)' }}>
<Calendar className="w-4 h-4" /> Modified
</div>
<span style={{ color: 'var(--color-text-primary)' }}>{new Date(data.info.mod_time).toLocaleString()}</span>
</div>
{data.created_at && (
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2" style={{ color: 'var(--color-text-secondary)' }}>
<Calendar className="w-4 h-4" /> Uploaded
</div>
<span style={{ color: 'var(--color-text-primary)' }}>{new Date(data.created_at).toLocaleString()}</span>
</div>
)}
</div>
</div>
) : (
<div className="text-center text-sm py-10" style={{ color: 'var(--color-text-secondary)' }}>
Could not load file information.
</div>
)}
</div>
</motion.div>
</div>
);
}

View file

@ -0,0 +1,125 @@
'use client';
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { Link as LinkIcon, Trash2, Calendar, Download, Shield, Copy, Check, RefreshCw } from 'lucide-react';
import api from '@/lib/api';
export default function SharedFilesPage() {
const [shares, setShares] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [copiedId, setCopiedId] = useState<string | null>(null);
useEffect(() => {
loadShares();
}, []);
async function loadShares() {
setLoading(true);
try {
const data = await api.listShares();
setShares(data.shares || []);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}
async function handleRevoke(id: string) {
if (!confirm('Are you sure you want to revoke this share link?')) return;
try {
await api.revokeShare(id);
setShares(prev => prev.filter(s => s.id !== id));
} catch (err) {
console.error(err);
}
}
function handleCopy(id: string) {
const url = `${window.location.origin}/share/${id}`;
navigator.clipboard.writeText(url);
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
}
if (loading) {
return (
<div className="flex-1 flex justify-center items-center">
<RefreshCw className="w-6 h-6 animate-spin" style={{ color: 'var(--color-text-tertiary)' }} />
</div>
);
}
return (
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-5xl mx-auto">
<h2 className="text-xl font-semibold mb-6" style={{ color: 'var(--color-text-primary)' }}>Shared Files</h2>
{shares.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<LinkIcon className="w-12 h-12 mb-4" style={{ color: 'var(--color-text-tertiary)' }} />
<p className="text-sm font-medium" style={{ color: 'var(--color-text-secondary)' }}>You don't have any active share links.</p>
</div>
) : (
<div className="rounded-2xl border overflow-hidden" style={{ backgroundColor: 'var(--color-bg-elevated)', borderColor: 'var(--color-border)' }}>
<table className="w-full text-sm text-left">
<thead className="border-b" style={{ borderColor: 'var(--color-border-subtle)', backgroundColor: 'var(--color-bg-secondary)' }}>
<tr>
<th className="px-6 py-4 font-semibold" style={{ color: 'var(--color-text-primary)' }}>File Path</th>
<th className="px-6 py-4 font-semibold" style={{ color: 'var(--color-text-primary)' }}>Link</th>
<th className="px-6 py-4 font-semibold" style={{ color: 'var(--color-text-primary)' }}>Downloads</th>
<th className="px-6 py-4 font-semibold" style={{ color: 'var(--color-text-primary)' }}>Expires</th>
<th className="px-6 py-4 font-semibold text-right" style={{ color: 'var(--color-text-primary)' }}>Actions</th>
</tr>
</thead>
<tbody className="divide-y" style={{ borderColor: 'var(--color-border-subtle)' }}>
{shares.map((share) => (
<tr key={share.id} className="hover:bg-black/5 dark:hover:bg-white/5 transition-colors">
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<span style={{ color: 'var(--color-text-primary)' }} className="truncate max-w-[200px]" title={share.path}>{share.path}</span>
{share.has_password && <span title="Password Protected"><Shield className="w-3.5 h-3.5" style={{ color: 'var(--color-warning)' }} /></span>}
</div>
</td>
<td className="px-6 py-4">
<button
onClick={() => handleCopy(share.id)}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg transition-colors text-xs font-medium"
style={{ backgroundColor: 'var(--color-accent-subtle)', color: 'var(--color-accent)' }}
>
{copiedId === share.id ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
{copiedId === share.id ? 'Copied!' : 'Copy Link'}
</button>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-1.5" style={{ color: 'var(--color-text-secondary)' }}>
<Download className="w-3.5 h-3.5" />
{share.downloads} {share.max_downloads ? `/ ${share.max_downloads}` : ''}
</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-1.5" style={{ color: 'var(--color-text-secondary)' }}>
<Calendar className="w-3.5 h-3.5" />
{share.expires_at ? new Date(share.expires_at).toLocaleDateString() : 'Never'}
</div>
</td>
<td className="px-6 py-4 text-right">
<button
onClick={() => handleRevoke(share.id)}
className="p-2 rounded-lg hover:bg-red-500/10 text-red-500 transition-colors"
title="Revoke Share Link"
>
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View file

@ -151,6 +151,11 @@ class ApiClient {
return res.json();
}
async getExtendedFileInfo(path: string) {
const res = await this.request(`/api/files/info/${encodeURIComponent(path)}`);
return res.json();
}
async createFolder(path: string) {
const res = await this.request('/api/files/mkdir', {
method: 'POST',
@ -331,7 +336,9 @@ class ApiClient {
method: 'POST',
body: { path, password, expires_in: expiresIn, max_downloads: maxDownloads },
});
return res.json();
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to create share');
return data;
}
async listShares() {