Shared file bug fixes, redesigned pin system, new info button
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m15s
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m15s
This commit is contained in:
parent
71ba029fbc
commit
2bcfca983c
7 changed files with 511 additions and 276 deletions
|
|
@ -160,6 +160,66 @@ func (h *FSHandler) getFileInfo(c *fiber.Ctx, absPath string) error {
|
||||||
return c.JSON(fi)
|
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.
|
// CreateFolder creates a new directory.
|
||||||
// POST /api/files/mkdir
|
// POST /api/files/mkdir
|
||||||
func (h *FSHandler) CreateFolder(c *fiber.Ctx) error {
|
func (h *FSHandler) CreateFolder(c *fiber.Ctx) error {
|
||||||
|
|
|
||||||
|
|
@ -35,10 +35,16 @@ func (h *ShareHandler) CreateShare(c *fiber.Ctx) error {
|
||||||
|
|
||||||
// Validate path
|
// Validate path
|
||||||
fullPath := filepath.Join(h.Config.StorageDir, filepath.FromSlash(req.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"})
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file not found"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isDirInt := 0
|
||||||
|
if info.IsDir() {
|
||||||
|
isDirInt = 1
|
||||||
|
}
|
||||||
|
|
||||||
id := uuid.New().String()
|
id := uuid.New().String()
|
||||||
var hash *string
|
var hash *string
|
||||||
if req.Password != "" {
|
if req.Password != "" {
|
||||||
|
|
@ -60,10 +66,10 @@ func (h *ShareHandler) CreateShare(c *fiber.Ctx) error {
|
||||||
maxDownloads = &req.MaxDownloads
|
maxDownloads = &req.MaxDownloads
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := h.DB.Exec(`
|
_, err = h.DB.Exec(`
|
||||||
INSERT INTO share_links (id, file_path, password_hash, expires_at, max_downloads)
|
INSERT INTO share_links (token, target_path, is_dir, password_hash, expires_at, max_downloads, download_count)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, 0)
|
||||||
`, id, req.Path, hash, expiresAt, maxDownloads)
|
`, id, req.Path, isDirInt, hash, expiresAt, maxDownloads)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to create share"})
|
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
|
// GET /api/share
|
||||||
func (h *ShareHandler) ListShares(c *fiber.Ctx) error {
|
func (h *ShareHandler) ListShares(c *fiber.Ctx) error {
|
||||||
rows, err := h.DB.Query(`
|
rows, err := h.DB.Query(`
|
||||||
SELECT id, file_path, expires_at, downloads, max_downloads, created_at,
|
SELECT token, target_path, expires_at, download_count, max_downloads, created_at,
|
||||||
(password_hash IS NOT NULL) as has_password
|
(password_hash IS NOT NULL AND password_hash != '') as has_password
|
||||||
FROM share_links
|
FROM share_links
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
`)
|
`)
|
||||||
|
|
@ -93,16 +99,29 @@ func (h *ShareHandler) ListShares(c *fiber.Ctx) error {
|
||||||
|
|
||||||
var shares []map[string]interface{}
|
var shares []map[string]interface{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var id, path, createdAt string
|
var token, path, createdAt string
|
||||||
var expiresAt *string
|
var expiresAt *string
|
||||||
var downloads int
|
var downloads int
|
||||||
var maxDownloads *int
|
var maxDownloads *int
|
||||||
var hasPassword bool
|
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{}{
|
shares = append(shares, map[string]interface{}{
|
||||||
"id": id,
|
"id": token,
|
||||||
"path": path,
|
"path": path,
|
||||||
"expires_at": expiresAt,
|
"expires_at": expiresAt,
|
||||||
"downloads": downloads,
|
"downloads": downloads,
|
||||||
|
|
@ -119,7 +138,7 @@ func (h *ShareHandler) ListShares(c *fiber.Ctx) error {
|
||||||
// DELETE /api/share/:id
|
// DELETE /api/share/:id
|
||||||
func (h *ShareHandler) RevokeShare(c *fiber.Ctx) error {
|
func (h *ShareHandler) RevokeShare(c *fiber.Ctx) error {
|
||||||
id := c.Params("id")
|
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 {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to revoke"})
|
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
|
var downloads int
|
||||||
|
|
||||||
err := h.DB.QueryRow(`
|
err := h.DB.QueryRow(`
|
||||||
SELECT file_path, password_hash, expires_at, max_downloads, downloads
|
SELECT target_path, password_hash, expires_at, max_downloads, download_count
|
||||||
FROM share_links WHERE id = ?
|
FROM share_links WHERE token = ?
|
||||||
`, id).Scan(&path, &hash, &expiresAt, &maxDownloads, &downloads)
|
`, id).Scan(&path, &hash, &expiresAt, &maxDownloads, &downloads)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -204,8 +223,8 @@ func (h *ShareHandler) DownloadPublicShare(c *fiber.Ctx) error {
|
||||||
var downloads int
|
var downloads int
|
||||||
|
|
||||||
err := h.DB.QueryRow(`
|
err := h.DB.QueryRow(`
|
||||||
SELECT file_path, password_hash, expires_at, max_downloads, downloads
|
SELECT target_path, password_hash, expires_at, max_downloads, download_count
|
||||||
FROM share_links WHERE id = ?
|
FROM share_links WHERE token = ?
|
||||||
`, id).Scan(&path, &hash, &expiresAt, &maxDownloads, &downloads)
|
`, id).Scan(&path, &hash, &expiresAt, &maxDownloads, &downloads)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -229,7 +248,7 @@ func (h *ShareHandler) DownloadPublicShare(c *fiber.Ctx) error {
|
||||||
fullPath := filepath.Join(h.Config.StorageDir, filepath.FromSlash(path))
|
fullPath := filepath.Join(h.Config.StorageDir, filepath.FromSlash(path))
|
||||||
|
|
||||||
// Increment download counter
|
// 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())
|
h.DB.AddAuditLog("share_downloaded", fmt.Sprintf("Downloaded share %s", id), c.IP())
|
||||||
|
|
||||||
return c.SendFile(fullPath)
|
return c.SendFile(fullPath)
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,7 @@ func main() {
|
||||||
|
|
||||||
// File listing and download (with path jail)
|
// File listing and download (with path jail)
|
||||||
files := protected.Group("/files", middleware.PathJail(cfg.StorageDir))
|
files := protected.Group("/files", middleware.PathJail(cfg.StorageDir))
|
||||||
|
files.Get("/info/*", fsHandler.GetExtendedFileInfo)
|
||||||
files.Get("/thumbnail/:checksum", fsHandler.ServeThumbnail)
|
files.Get("/thumbnail/:checksum", fsHandler.ServeThumbnail)
|
||||||
files.Get("/download/*", fsHandler.Download)
|
files.Get("/download/*", fsHandler.Download)
|
||||||
files.Post("/zip", archiveHandler.Zip)
|
files.Post("/zip", archiveHandler.Zip)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ import SettingsPage from './SettingsPage';
|
||||||
import StorageDashboard from './StorageDashboard';
|
import StorageDashboard from './StorageDashboard';
|
||||||
import TaskManager from './TaskManager';
|
import TaskManager from './TaskManager';
|
||||||
import ShareModal from './ShareModal';
|
import ShareModal from './ShareModal';
|
||||||
|
import FileInfoModal from './FileInfoModal';
|
||||||
|
import SharedFilesPage from './SharedFilesPage';
|
||||||
|
|
||||||
interface FileItem {
|
interface FileItem {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -34,7 +36,7 @@ interface FileExplorerProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
type ViewMode = 'grid' | 'list';
|
type ViewMode = 'grid' | 'list';
|
||||||
type SidebarSection = 'files' | 'pinned' | 'trash' | 'storage' | 'settings';
|
type SidebarSection = 'files' | 'trash' | 'storage' | 'settings' | 'shared';
|
||||||
|
|
||||||
interface UploadProgress {
|
interface UploadProgress {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -80,7 +82,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
const [newFolderName, setNewFolderName] = useState('');
|
const [newFolderName, setNewFolderName] = useState('');
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
const [previewFile, setPreviewFile] = useState<FileItem | null>(null);
|
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 [sharingFile, setSharingFile] = useState<string | null>(null);
|
||||||
const [sortBy, setSortBy] = useState<'name' | 'size' | 'date' | 'type'>('name');
|
const [sortBy, setSortBy] = useState<'name' | 'size' | 'date' | 'type'>('name');
|
||||||
const [sortAsc, setSortAsc] = useState(true);
|
const [sortAsc, setSortAsc] = useState(true);
|
||||||
|
|
@ -92,6 +94,8 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
const [moveModalPath, setMoveModalPath] = useState('.');
|
const [moveModalPath, setMoveModalPath] = useState('.');
|
||||||
const [moveFolders, setMoveFolders] = useState<FileItem[]>([]);
|
const [moveFolders, setMoveFolders] = useState<FileItem[]>([]);
|
||||||
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
|
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 searchTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
|
@ -105,6 +109,11 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
if (s) {
|
if (s) {
|
||||||
if (s.grid_size) setGridSize(s.grid_size);
|
if (s.grid_size) setGridSize(s.grid_size);
|
||||||
if (s.trash_auto_purge_days) setTrashAutoPurgeDays(parseInt(s.trash_auto_purge_days, 10));
|
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';
|
const fetchedTheme = s.theme || 'dark';
|
||||||
setTheme(fetchedTheme);
|
setTheme(fetchedTheme);
|
||||||
if (fetchedTheme === 'light') {
|
if (fetchedTheme === 'light') {
|
||||||
|
|
@ -249,13 +258,55 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
if (section === 'files') {
|
if (section === 'files') {
|
||||||
navigateTo('.');
|
navigateTo('.');
|
||||||
loadFiles();
|
loadFiles();
|
||||||
} else if (section === 'pinned') {
|
|
||||||
loadPinned();
|
|
||||||
} else if (section === 'trash') {
|
} else if (section === 'trash') {
|
||||||
loadTrash();
|
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() {
|
function navigateUp() {
|
||||||
if (currentPath === '.' || currentPath === '') return;
|
if (currentPath === '.' || currentPath === '') return;
|
||||||
const parts = currentPath.split('/');
|
const parts = currentPath.split('/');
|
||||||
|
|
@ -328,6 +379,20 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
async function handlePin(file: FileItem) {
|
async function handlePin(file: FileItem) {
|
||||||
await api.togglePin(file.path);
|
await api.togglePin(file.path);
|
||||||
loadFiles();
|
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) {
|
async function handleRename(path: string) {
|
||||||
|
|
@ -559,10 +624,9 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
});
|
});
|
||||||
|
|
||||||
const groupedFiles = useMemo(() => {
|
const groupedFiles = useMemo(() => {
|
||||||
const baseFiles = displayFiles.filter(f => activeSection !== 'files' || !f.is_pinned);
|
const baseFiles = displayFiles;
|
||||||
if (activeSection !== 'trash' || trashAutoPurgeDays <= 0) {
|
if (activeSection !== 'trash' || trashAutoPurgeDays <= 0) {
|
||||||
const defaultName = activeSection === 'files' && displayFiles.some(f => f.is_pinned) ? 'All Files' : '';
|
return [{ name: '', files: baseFiles }];
|
||||||
return [{ name: defaultName, files: baseFiles }];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
@ -638,10 +702,81 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* 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: '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: 'trash' as SidebarSection, icon: Trash2, label: 'Trash' },
|
||||||
{ id: 'storage' as SidebarSection, icon: HardDrive, label: 'Storage' },
|
{ id: 'storage' as SidebarSection, icon: HardDrive, label: 'Storage' },
|
||||||
].map(({ id, icon: Icon, label }) => (
|
].map(({ id, icon: Icon, label }) => (
|
||||||
|
|
@ -659,7 +794,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Settings Footer */}
|
{/* Settings Footer */}
|
||||||
|
|
@ -760,7 +894,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(activeSection === 'files' || activeSection === 'pinned' || activeSection === 'trash') && (
|
{(activeSection === 'files' || activeSection === 'trash' || activeSection === 'shared') && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={toggleTheme}
|
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" />}
|
{theme === 'dark' ? <Sun className="w-4.5 h-4.5" /> : <Moon className="w-4.5 h-4.5" />}
|
||||||
</button>
|
</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="w-px h-5 mx-1" style={{ backgroundColor: 'var(--color-border-subtle)' }} />
|
||||||
|
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
|
|
@ -856,6 +977,8 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
<SettingsPage onLogout={onLogout} />
|
<SettingsPage onLogout={onLogout} />
|
||||||
) : activeSection === 'storage' ? (
|
) : activeSection === 'storage' ? (
|
||||||
<StorageDashboard />
|
<StorageDashboard />
|
||||||
|
) : activeSection === 'shared' ? (
|
||||||
|
<SharedFilesPage />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* File Area */}
|
{/* File Area */}
|
||||||
|
|
@ -943,87 +1066,9 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
) : viewMode === 'grid' ? (
|
) : viewMode === 'grid' ? (
|
||||||
/* Grid View */
|
/* Grid View */
|
||||||
<div className="space-y-8">
|
<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>
|
<div>
|
||||||
{groupedFiles.map((group, groupIdx) => (
|
{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 && (
|
{group.name && (
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wider mb-3 px-1" style={{ color: 'var(--color-text-tertiary)' }}>{group.name}</h3>
|
<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 */
|
/* List View */
|
||||||
<div className="space-y-8">
|
<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>
|
<div>
|
||||||
{groupedFiles.map((group, groupIdx) => (
|
{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 && (
|
{group.name && (
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wider mb-2 px-4" style={{ color: 'var(--color-text-tertiary)' }}>{group.name}</h3>
|
<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>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</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
|
<ContextMenuItem
|
||||||
icon={<Download className="w-4 h-4" />}
|
icon={<Download className="w-4 h-4" />}
|
||||||
label="Download"
|
label="Download"
|
||||||
|
|
@ -1587,6 +1485,12 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{infoFile && (
|
||||||
|
<FileInfoModal filePath={infoFile.path} onClose={() => setInfoFile(null)} />
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{showTrashModal && (
|
{showTrashModal && (
|
||||||
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
|
|
|
||||||
119
frontend/src/components/FileInfoModal.tsx
Normal file
119
frontend/src/components/FileInfoModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
frontend/src/components/SharedFilesPage.tsx
Normal file
125
frontend/src/components/SharedFilesPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -151,6 +151,11 @@ class ApiClient {
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getExtendedFileInfo(path: string) {
|
||||||
|
const res = await this.request(`/api/files/info/${encodeURIComponent(path)}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
async createFolder(path: string) {
|
async createFolder(path: string) {
|
||||||
const res = await this.request('/api/files/mkdir', {
|
const res = await this.request('/api/files/mkdir', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -331,7 +336,9 @@ class ApiClient {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { path, password, expires_in: expiresIn, max_downloads: maxDownloads },
|
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() {
|
async listShares() {
|
||||||
|
|
|
||||||
Reference in a new issue