fix(frontend): update Share interface property from download_count to downloads
All checks were successful
Automated Container Build / build-and-push (push) Successful in 3m25s

This commit is contained in:
Elijah 2026-05-26 14:54:50 -07:00
parent ed3eed6bb7
commit 1f83d57942
21 changed files with 241 additions and 137 deletions

5
.gitignore vendored
View file

@ -1,3 +1,8 @@
# OS Files
.DS_Store
Thumbs.db
desktop.ini
# IDE & Editor files # IDE & Editor files
.vscode/ .vscode/
.cursor/ .cursor/

View file

@ -1,7 +0,0 @@
package main
import "fmt"
func main() {
fmt.Println("Running...")
}

View file

@ -722,8 +722,6 @@ func (h *FSHandler) RestoreFromTrash(c *fiber.Ctx) error {
) )
} }
h.DB.AddAuditLog("restored", originalPath, c.IP())
h.DB.AddAuditLog("restored", fmt.Sprintf("Restored '%s' from trash", originalPath), c.IP()) h.DB.AddAuditLog("restored", fmt.Sprintf("Restored '%s' from trash", originalPath), c.IP())
return c.JSON(fiber.Map{"message": "restored successfully", "path": originalPath}) return c.JSON(fiber.Map{"message": "restored successfully", "path": originalPath})
@ -1024,10 +1022,6 @@ func (h *FSHandler) Download(c *fiber.Ctx) error {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file not found"}) return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file not found"})
} }
// Verify checksum on download if stored
relativePath := c.Locals("relativePath").(string)
var storedChecksum string
h.DB.QueryRow("SELECT checksum FROM files WHERE path = ?", filepath.ToSlash(relativePath)).Scan(&storedChecksum)
// Set appropriate headers for range requests // Set appropriate headers for range requests
c.Set("Accept-Ranges", "bytes") c.Set("Accept-Ranges", "bytes")

View file

@ -1,9 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [],
theme: {
extend: {},
},
plugins: [],
}

View file

@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,32 @@
'use client';
import { useEffect } from 'react';
export default function ErrorBoundary({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className="flex h-screen w-screen items-center justify-center bg-[#0a0a0a] text-white">
<div className="text-center p-8 border border-white/10 rounded-2xl bg-black/50">
<h2 className="text-2xl font-bold mb-4">Something went wrong!</h2>
<p className="text-white/60 mb-8 max-w-md mx-auto">
We encountered an unexpected error. You can try recovering by clicking the button below.
</p>
<button
onClick={() => reset()}
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg font-medium transition-colors"
>
Try again
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,21 @@
import Link from 'next/link';
export default function NotFound() {
return (
<div className="flex h-screen w-screen items-center justify-center bg-[#0a0a0a] text-white">
<div className="text-center">
<h1 className="text-6xl font-bold mb-4">404</h1>
<h2 className="text-2xl font-semibold mb-6">Page Not Found</h2>
<p className="text-white/60 mb-8 max-w-md mx-auto">
The page you are looking for doesn't exist or has been moved.
</p>
<Link
href="/"
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg font-medium transition-colors"
>
Return Home
</Link>
</div>
</div>
);
}

View file

@ -10,6 +10,8 @@ import {
Sun, Moon, Music as MusicIcon, Code as CodeIcon, Archive as ArchiveIcon, CornerDownRight, Sun, Moon, Music as MusicIcon, Code as CodeIcon, Archive as ArchiveIcon, CornerDownRight,
CheckCircle, AlertCircle, Menu, FileText, LayoutTemplate, FileSpreadsheet CheckCircle, AlertCircle, Menu, FileText, LayoutTemplate, FileSpreadsheet
} from 'lucide-react'; } from 'lucide-react';
import { FileItem } from '@/types/files';
import { formatSize } from '@/lib/utils';
import api from '@/lib/api'; import api from '@/lib/api';
import { useIsMobile } from '@/hooks/useIsMobile'; import { useIsMobile } from '@/hooks/useIsMobile';
const Folder = ({ className, style }: { className?: string, style?: React.CSSProperties }) => ( const Folder = ({ className, style }: { className?: string, style?: React.CSSProperties }) => (
@ -30,18 +32,7 @@ import OfficeHome from './OfficeHome';
import OnlyOfficeEditor from './OnlyOfficeEditor'; import OnlyOfficeEditor from './OnlyOfficeEditor';
import ThumbnailImage from './ThumbnailImage'; import ThumbnailImage from './ThumbnailImage';
interface FileItem { // FileItem imported from types
name: string;
path: string;
is_dir: boolean;
size: number;
mime_type: string;
is_pinned: boolean;
is_trashed: boolean;
mod_time: string;
trashed_at?: string;
checksum?: string;
}
interface FileExplorerProps { interface FileExplorerProps {
onLogout: () => void; onLogout: () => void;
@ -427,7 +418,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [files, selectedFiles]); }, [files, selectedFiles, currentPath]);
useEffect(() => { useEffect(() => {
const handleClickOutside = () => { const handleClickOutside = () => {
@ -556,9 +547,10 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
if (target.closest('button') || target.closest('input') || target.closest('[data-file-item]')) { if (target.closest('button') || target.closest('input') || target.closest('[data-file-item]')) {
return; return;
} }
const rect = mainContainerRef.current!.getBoundingClientRect(); if (!mainContainerRef.current) return;
const scrollLeft = mainContainerRef.current!.scrollLeft; const rect = mainContainerRef.current.getBoundingClientRect();
const scrollTop = mainContainerRef.current!.scrollTop; const scrollLeft = mainContainerRef.current.scrollLeft;
const scrollTop = mainContainerRef.current.scrollTop;
setSelectionBox({ setSelectionBox({
startX: e.clientX - rect.left + scrollLeft, startX: e.clientX - rect.left + scrollLeft,
@ -1146,13 +1138,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
const pathSegments = currentPath === '.' ? [] : currentPath.split('/'); const pathSegments = currentPath === '.' ? [] : currentPath.split('/');
function formatSize(bytes: number): string {
if (bytes === 0) return '—';
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]}`;
}
function formatDate(dateStr: string): string { function formatDate(dateStr: string): string {
if (!dateStr) return ''; if (!dateStr) return '';
const date = new Date(dateStr); const date = new Date(dateStr);

View file

@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { X, File, Folder, HardDrive, Calendar, RefreshCw, FileType, Info } from 'lucide-react'; import { X, File, Folder, HardDrive, Calendar, RefreshCw, FileType, Info } from 'lucide-react';
import api from '@/lib/api'; import api from '@/lib/api';
import { formatSize } from '@/lib/utils';
interface FileInfoModalProps { interface FileInfoModalProps {
filePath: string; filePath: string;
@ -48,12 +49,7 @@ export default function FileInfoModal({ filePath, onClose }: FileInfoModalProps)
}); });
}, [filePath]); }, [filePath]);
function formatSize(bytes: number): string { // formatSize imported from utils
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 ( return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={onClose}> <div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={onClose}>

View file

@ -7,6 +7,7 @@ import api from '@/lib/api';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { formatSize, getFileDisplayName } from '@/lib/utils';
const DynamicPdfViewer = dynamic(() => import('./PdfViewer'), { ssr: false }); const DynamicPdfViewer = dynamic(() => import('./PdfViewer'), { ssr: false });
@ -21,11 +22,7 @@ interface FilePreviewProps {
downloadToken?: string; downloadToken?: string;
} }
function getFileDisplayName(name: string) { // getFileDisplayName imported from utils
const dotIndex = name.lastIndexOf('.');
if (dotIndex > 0) return name.substring(0, dotIndex);
return name;
}
export default function FilePreview({ file, onClose, downloadToken }: FilePreviewProps) { export default function FilePreview({ file, onClose, downloadToken }: FilePreviewProps) {
const [content, setContent] = useState<string | null>(null); const [content, setContent] = useState<string | null>(null);
@ -264,11 +261,6 @@ function getIcon(type: PreviewType) {
} }
} }
function formatSize(bytes: number): string { // formatSize imported from utils
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(1)} ${units[i]}`;
}

View file

@ -2,18 +2,9 @@ import React, { useState, useEffect, useRef } from 'react';
import { Plus, Pencil, Trash2, Pin, FileText, LayoutTemplate, FileSpreadsheet } from 'lucide-react'; import { Plus, Pencil, Trash2, Pin, FileText, LayoutTemplate, FileSpreadsheet } from 'lucide-react';
import api from '@/lib/api'; import api from '@/lib/api';
import ThumbnailImage from './ThumbnailImage'; import ThumbnailImage from './ThumbnailImage';
import { FileItem } from '@/types/files';
interface FileItem { // FileItem imported from types
name: string;
path: string;
is_dir: boolean;
size: number;
mime_type: string;
is_pinned: boolean;
is_trashed: boolean;
mod_time: string;
checksum?: string;
}
interface OfficeHomeProps { interface OfficeHomeProps {
type: 'docs' | 'slides' | 'sheets'; type: 'docs' | 'slides' | 'sheets';
@ -64,6 +55,7 @@ export default function OfficeHome({ type, onFileSelect, onCreateBlank, onPinCha
const [renameValue, setRenameValue] = useState(''); const [renameValue, setRenameValue] = useState('');
const renameInputRef = useRef<HTMLInputElement>(null); const renameInputRef = useRef<HTMLInputElement>(null);
const [downloadToken, setDownloadToken] = useState(''); const [downloadToken, setDownloadToken] = useState('');
const [toast, setToast] = useState<{message: string, type: 'error' | 'success'} | null>(null);
const ext = type === 'docs' ? '.docx' : type === 'slides' ? '.pptx' : '.xlsx'; const ext = type === 'docs' ? '.docx' : type === 'slides' ? '.pptx' : '.xlsx';
const fileTypeParam = type === 'docs' ? 'docx' : type === 'slides' ? 'pptx' : 'xlsx'; const fileTypeParam = type === 'docs' ? 'docx' : type === 'slides' ? 'pptx' : 'xlsx';
@ -208,8 +200,10 @@ export default function OfficeHome({ type, onFileSelect, onCreateBlank, onPinCha
await api.rename(renaming, trimmed); await api.rename(renaming, trimmed);
setRenaming(null); setRenaming(null);
fetchFiles(); // Refresh fetchFiles(); // Refresh
} catch (err) { } catch (err: any) {
console.error("Failed to rename", err); console.error("Failed to rename", err);
setToast({ message: err.message || "Failed to rename file", type: "error" });
setTimeout(() => setToast(null), 3000);
setRenaming(null); setRenaming(null);
} }
}; };
@ -384,6 +378,17 @@ export default function OfficeHome({ type, onFileSelect, onCreateBlank, onPinCha
</button> </button>
</div> </div>
)} )}
{/* Toast Notification */}
{toast && (
<div className="fixed bottom-4 right-4 z-[200]">
<div className={`px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 ${
toast.type === 'error' ? 'bg-red-500 text-white' : 'bg-green-500 text-white'
}`}>
<span className="font-medium text-sm">{toast.message}</span>
</div>
</div>
)}
</div> </div>
); );
} }

View file

@ -1,18 +1,8 @@
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import { DocumentEditor } from '@onlyoffice/document-editor-react'; import { DocumentEditor } from '@onlyoffice/document-editor-react';
import { Sun, Moon } from 'lucide-react'; import { Sun, Moon, Loader2, ArrowLeft, RefreshCw, Download } from 'lucide-react';
import api from '@/lib/api'; import api from '@/lib/api';
import { FileItem } from '@/types/files';
interface FileItem {
name: string;
path: string;
is_dir: boolean;
size: number;
mime_type: string;
is_pinned: boolean;
is_trashed: boolean;
mod_time: string;
}
interface OnlyOfficeEditorProps { interface OnlyOfficeEditorProps {
file: FileItem; file: FileItem;

View file

@ -41,11 +41,22 @@ export default function ShareModal({ filePath, onClose }: ShareModalProps) {
} }
} }
function handleCopy() { async function handleCopy() {
if (!shareLink) return; if (!shareLink) return;
navigator.clipboard.writeText(shareLink); try {
setCopied(true); await navigator.clipboard.writeText(shareLink);
setTimeout(() => setCopied(false), 2000); setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
const textArea = document.createElement('textarea');
textArea.value = shareLink;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
} }
return ( return (

View file

@ -5,8 +5,18 @@ import { motion } from 'framer-motion';
import { Link as LinkIcon, Trash2, Calendar, Download, Shield, Copy, Check, RefreshCw } from 'lucide-react'; import { Link as LinkIcon, Trash2, Calendar, Download, Shield, Copy, Check, RefreshCw } from 'lucide-react';
import api from '@/lib/api'; import api from '@/lib/api';
interface Share {
id: string;
path: string;
has_password: boolean;
expires_at: string | null;
max_downloads: number;
downloads: number;
created_at: string;
}
export default function SharedFilesPage() { export default function SharedFilesPage() {
const [shares, setShares] = useState<any[]>([]); const [shares, setShares] = useState<Share[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [copiedId, setCopiedId] = useState<string | null>(null); const [copiedId, setCopiedId] = useState<string | null>(null);
const [shareToRevoke, setShareToRevoke] = useState<string | null>(null); const [shareToRevoke, setShareToRevoke] = useState<string | null>(null);
@ -42,11 +52,22 @@ export default function SharedFilesPage() {
} }
} }
function handleCopy(id: string) { async function handleCopy(id: string) {
const url = `${window.location.origin}/share/${id}`; const url = `${window.location.origin}/share/${id}`;
navigator.clipboard.writeText(url); try {
setCopiedId(id); await navigator.clipboard.writeText(url);
setTimeout(() => setCopiedId(null), 2000); setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
} catch {
const textArea = document.createElement('textarea');
textArea.value = url;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
}
} }
if (loading) { if (loading) {
@ -68,8 +89,8 @@ export default function SharedFilesPage() {
<p className="text-sm font-medium" style={{ color: 'var(--color-text-secondary)' }}>You don't have any active share links.</p> <p className="text-sm font-medium" style={{ color: 'var(--color-text-secondary)' }}>You don't have any active share links.</p>
</div> </div>
) : ( ) : (
<div className="rounded-2xl border overflow-hidden" style={{ backgroundColor: 'var(--color-bg-elevated)', borderColor: 'var(--color-border)' }}> <div className="rounded-2xl border overflow-x-auto" style={{ backgroundColor: 'var(--color-bg-elevated)', borderColor: 'var(--color-border)' }}>
<table className="w-full text-sm text-left"> <table className="w-full text-sm text-left whitespace-nowrap">
<thead className="border-b" style={{ borderColor: 'var(--color-border-subtle)', backgroundColor: 'var(--color-bg-secondary)' }}> <thead className="border-b" style={{ borderColor: 'var(--color-border-subtle)', backgroundColor: 'var(--color-bg-secondary)' }}>
<tr> <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)' }}>File Path</th>

View file

@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { HardDrive, Loader2, Image as ImageIcon, Film, Music, FileText, Archive, File } from 'lucide-react'; import { HardDrive, Loader2, Image as ImageIcon, Film, Music, FileText, Archive, File } from 'lucide-react';
import api from '@/lib/api'; import api from '@/lib/api';
import { formatSize } from '@/lib/utils';
interface CategoryUsage { interface CategoryUsage {
category: string; category: string;
@ -61,8 +62,6 @@ export default function StorageDashboard() {
other: { label: 'Other', color: '#9ca3af', icon: File }, other: { label: 'Other', color: '#9ca3af', icon: File },
}; };
const totalDriveSize = 100 * 1024 * 1024 * 1024; // Dummy 100GB capacity for visualization, could be dynamic
const sortedCategories = [...data.categories].sort((a, b) => b.size - a.size); const sortedCategories = [...data.categories].sort((a, b) => b.size - a.size);
return ( return (
@ -139,9 +138,4 @@ export default function StorageDashboard() {
); );
} }
function formatSize(bytes: number): string { // formatSize imported from utils
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(1)} ${units[i]}`;
}

View file

@ -21,7 +21,7 @@ export default function TaskManager() {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
useEffect(() => { useEffect(() => {
let interval: NodeJS.Timeout; let interval: NodeJS.Timeout | null = null;
async function pollTasks() { async function pollTasks() {
try { try {
@ -29,22 +29,39 @@ export default function TaskManager() {
const activeTasks = data.tasks || []; const activeTasks = data.tasks || [];
setTasks(activeTasks); setTasks(activeTasks);
// Auto-show if there are new active tasks
if (activeTasks.some((t: Task) => t.status === 'running' || t.status === 'pending')) { if (activeTasks.some((t: Task) => t.status === 'running' || t.status === 'pending')) {
setVisible(true); setVisible(true);
} else if (activeTasks.length === 0) { } else if (activeTasks.length === 0) {
setVisible(false); setVisible(false);
if (interval) {
clearInterval(interval);
interval = null;
}
} }
} catch { } catch {
// silently fail polling // silently fail polling
} }
} }
// Always fetch once on mount/update
pollTasks(); pollTasks();
interval = setInterval(pollTasks, 2000); // Poll every 2 seconds
return () => clearInterval(interval); const hasRunningTasks = tasks.some(t => t.status === 'running' || t.status === 'pending');
}, []); if (hasRunningTasks) {
interval = setInterval(pollTasks, 2000);
}
// Listen for activity that might spawn tasks
const handleActivity = () => {
if (!hasRunningTasks) pollTasks();
};
window.addEventListener('api_activity', handleActivity);
return () => {
if (interval) clearInterval(interval);
window.removeEventListener('api_activity', handleActivity);
};
}, [tasks.some(t => t.status === 'running' || t.status === 'pending')]);
if (!visible || tasks.length === 0) return null; if (!visible || tasks.length === 0) return null;
@ -58,9 +75,8 @@ export default function TaskManager() {
style={{ backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }} style={{ backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}
> >
{/* Header */} {/* Header */}
<button <div
onClick={() => setExpanded(!expanded)} className="flex items-center justify-between px-4 py-3 border-b transition-colors"
className="flex items-center justify-between px-4 py-3 border-b transition-colors hover:bg-white/5"
style={{ borderColor: 'var(--color-border-subtle)', backgroundColor: 'var(--color-bg-secondary)' }} style={{ borderColor: 'var(--color-border-subtle)', backgroundColor: 'var(--color-bg-secondary)' }}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -74,15 +90,22 @@ export default function TaskManager() {
</span> </span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{expanded ? <ChevronDown className="w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} /> : <ChevronUp className="w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />} <button
<div onClick={() => setExpanded(!expanded)}
onClick={(e) => { e.stopPropagation(); setVisible(false); }}
className="p-1 rounded hover:bg-white/10" className="p-1 rounded hover:bg-white/10"
aria-label={expanded ? "Collapse tasks" : "Expand tasks"}
>
{expanded ? <ChevronDown className="w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} /> : <ChevronUp className="w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />}
</button>
<button
onClick={() => setVisible(false)}
className="p-1 rounded hover:bg-white/10"
aria-label="Close task manager"
> >
<X className="w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} /> <X className="w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
</div> </button>
</div> </div>
</button> </div>
{/* Task List */} {/* Task List */}
<AnimatePresence> <AnimatePresence>

View file

@ -2,23 +2,20 @@ import { useState, useEffect } from 'react';
const MOBILE_BREAKPOINT = 768; const MOBILE_BREAKPOINT = 768;
export function useIsMobile() { export function useIsMobile(breakpoint = 768) {
const [isMobile, setIsMobile] = useState<boolean>(false); const [isMobile, setIsMobile] = useState<boolean>(false);
const [hasMounted, setHasMounted] = useState(false);
useEffect(() => { useEffect(() => {
// Initial check setHasMounted(true);
const checkIsMobile = () => { const mql = window.matchMedia(`(max-width: ${breakpoint}px)`);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); setIsMobile(mql.matches);
};
checkIsMobile();
// Add resize listener const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
window.addEventListener('resize', checkIsMobile); mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
// Cleanup }, [breakpoint]);
return () => window.removeEventListener('resize', checkIsMobile);
}, []);
if (!hasMounted) return false;
return isMobile; return isMobile;
} }

View file

@ -91,6 +91,12 @@ class ApiClient {
} catch {} } catch {}
throw new Error(errorMsg); throw new Error(errorMsg);
} }
// Notify TaskManager to poll if a task might have been created
if (typeof window !== 'undefined' && endpoint !== '/api/tasks' && endpoint !== '/api/auth/refresh') {
window.dispatchEvent(new Event('api_activity'));
}
return response; return response;
} }

11
frontend/src/lib/utils.ts Normal file
View file

@ -0,0 +1,11 @@
export function formatSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
export function getFileDisplayName(path: string): string {
return path.split('/').pop() || path;
}

View file

@ -0,0 +1,12 @@
export interface FileItem {
name: string;
path: string;
is_dir: boolean;
size: number;
mime_type: string;
is_pinned: boolean;
is_trashed: boolean;
trashed_at?: string;
mod_time: string;
checksum?: string;
}