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
All checks were successful
Automated Container Build / build-and-push (push) Successful in 3m25s
This commit is contained in:
parent
ed3eed6bb7
commit
1f83d57942
21 changed files with 241 additions and 137 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -1,3 +1,8 @@
|
|||
# OS Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# IDE & Editor files
|
||||
.vscode/
|
||||
.cursor/
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
fmt.Println("Running...")
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
||||
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"})
|
||||
}
|
||||
|
||||
// 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
|
||||
c.Set("Accept-Ranges", "bytes")
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
31
frontend/public/pdf.worker.min.mjs
Normal file
31
frontend/public/pdf.worker.min.mjs
Normal file
File diff suppressed because one or more lines are too long
32
frontend/src/app/error.tsx
Normal file
32
frontend/src/app/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
frontend/src/app/not-found.tsx
Normal file
21
frontend/src/app/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -10,6 +10,8 @@ import {
|
|||
Sun, Moon, Music as MusicIcon, Code as CodeIcon, Archive as ArchiveIcon, CornerDownRight,
|
||||
CheckCircle, AlertCircle, Menu, FileText, LayoutTemplate, FileSpreadsheet
|
||||
} from 'lucide-react';
|
||||
import { FileItem } from '@/types/files';
|
||||
import { formatSize } from '@/lib/utils';
|
||||
import api from '@/lib/api';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
const Folder = ({ className, style }: { className?: string, style?: React.CSSProperties }) => (
|
||||
|
|
@ -30,18 +32,7 @@ import OfficeHome from './OfficeHome';
|
|||
import OnlyOfficeEditor from './OnlyOfficeEditor';
|
||||
import ThumbnailImage from './ThumbnailImage';
|
||||
|
||||
interface FileItem {
|
||||
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;
|
||||
}
|
||||
// FileItem imported from types
|
||||
|
||||
interface FileExplorerProps {
|
||||
onLogout: () => void;
|
||||
|
|
@ -427,7 +418,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [files, selectedFiles]);
|
||||
}, [files, selectedFiles, currentPath]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = () => {
|
||||
|
|
@ -556,9 +547,10 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
if (target.closest('button') || target.closest('input') || target.closest('[data-file-item]')) {
|
||||
return;
|
||||
}
|
||||
const rect = mainContainerRef.current!.getBoundingClientRect();
|
||||
const scrollLeft = mainContainerRef.current!.scrollLeft;
|
||||
const scrollTop = mainContainerRef.current!.scrollTop;
|
||||
if (!mainContainerRef.current) return;
|
||||
const rect = mainContainerRef.current.getBoundingClientRect();
|
||||
const scrollLeft = mainContainerRef.current.scrollLeft;
|
||||
const scrollTop = mainContainerRef.current.scrollTop;
|
||||
|
||||
setSelectionBox({
|
||||
startX: e.clientX - rect.left + scrollLeft,
|
||||
|
|
@ -1146,13 +1138,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
|
||||
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 {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
|||
import { motion } from 'framer-motion';
|
||||
import { X, File, Folder, HardDrive, Calendar, RefreshCw, FileType, Info } from 'lucide-react';
|
||||
import api from '@/lib/api';
|
||||
import { formatSize } from '@/lib/utils';
|
||||
|
||||
interface FileInfoModalProps {
|
||||
filePath: string;
|
||||
|
|
@ -48,12 +49,7 @@ export default function FileInfoModal({ filePath, onClose }: FileInfoModalProps)
|
|||
});
|
||||
}, [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]}`;
|
||||
}
|
||||
// formatSize imported from utils
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={onClose}>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import api from '@/lib/api';
|
|||
import dynamic from 'next/dynamic';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { formatSize, getFileDisplayName } from '@/lib/utils';
|
||||
|
||||
const DynamicPdfViewer = dynamic(() => import('./PdfViewer'), { ssr: false });
|
||||
|
||||
|
|
@ -21,11 +22,7 @@ interface FilePreviewProps {
|
|||
downloadToken?: string;
|
||||
}
|
||||
|
||||
function getFileDisplayName(name: string) {
|
||||
const dotIndex = name.lastIndexOf('.');
|
||||
if (dotIndex > 0) return name.substring(0, dotIndex);
|
||||
return name;
|
||||
}
|
||||
// getFileDisplayName imported from utils
|
||||
|
||||
export default function FilePreview({ file, onClose, downloadToken }: FilePreviewProps) {
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
|
|
@ -264,11 +261,6 @@ function getIcon(type: PreviewType) {
|
|||
}
|
||||
}
|
||||
|
||||
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(1)} ${units[i]}`;
|
||||
}
|
||||
// formatSize imported from utils
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2,18 +2,9 @@ import React, { useState, useEffect, useRef } from 'react';
|
|||
import { Plus, Pencil, Trash2, Pin, FileText, LayoutTemplate, FileSpreadsheet } from 'lucide-react';
|
||||
import api from '@/lib/api';
|
||||
import ThumbnailImage from './ThumbnailImage';
|
||||
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;
|
||||
checksum?: string;
|
||||
}
|
||||
// FileItem imported from types
|
||||
|
||||
interface OfficeHomeProps {
|
||||
type: 'docs' | 'slides' | 'sheets';
|
||||
|
|
@ -64,6 +55,7 @@ export default function OfficeHome({ type, onFileSelect, onCreateBlank, onPinCha
|
|||
const [renameValue, setRenameValue] = useState('');
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
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 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);
|
||||
setRenaming(null);
|
||||
fetchFiles(); // Refresh
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
console.error("Failed to rename", err);
|
||||
setToast({ message: err.message || "Failed to rename file", type: "error" });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
setRenaming(null);
|
||||
}
|
||||
};
|
||||
|
|
@ -384,6 +378,17 @@ export default function OfficeHome({ type, onFileSelect, onCreateBlank, onPinCha
|
|||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,8 @@
|
|||
import React, { useEffect, useState, useRef } from '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';
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
path: string;
|
||||
is_dir: boolean;
|
||||
size: number;
|
||||
mime_type: string;
|
||||
is_pinned: boolean;
|
||||
is_trashed: boolean;
|
||||
mod_time: string;
|
||||
}
|
||||
import { FileItem } from '@/types/files';
|
||||
|
||||
interface OnlyOfficeEditorProps {
|
||||
file: FileItem;
|
||||
|
|
|
|||
|
|
@ -41,11 +41,22 @@ export default function ShareModal({ filePath, onClose }: ShareModalProps) {
|
|||
}
|
||||
}
|
||||
|
||||
function handleCopy() {
|
||||
async function handleCopy() {
|
||||
if (!shareLink) return;
|
||||
navigator.clipboard.writeText(shareLink);
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareLink);
|
||||
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 (
|
||||
|
|
|
|||
|
|
@ -5,8 +5,18 @@ import { motion } from 'framer-motion';
|
|||
import { Link as LinkIcon, Trash2, Calendar, Download, Shield, Copy, Check, RefreshCw } from 'lucide-react';
|
||||
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() {
|
||||
const [shares, setShares] = useState<any[]>([]);
|
||||
const [shares, setShares] = useState<Share[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [copiedId, setCopiedId] = 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}`;
|
||||
navigator.clipboard.writeText(url);
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
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) {
|
||||
|
|
@ -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>
|
||||
</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">
|
||||
<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 whitespace-nowrap">
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
|||
import { motion } from 'framer-motion';
|
||||
import { HardDrive, Loader2, Image as ImageIcon, Film, Music, FileText, Archive, File } from 'lucide-react';
|
||||
import api from '@/lib/api';
|
||||
import { formatSize } from '@/lib/utils';
|
||||
|
||||
interface CategoryUsage {
|
||||
category: string;
|
||||
|
|
@ -61,8 +62,6 @@ export default function StorageDashboard() {
|
|||
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);
|
||||
|
||||
return (
|
||||
|
|
@ -139,9 +138,4 @@ export default function StorageDashboard() {
|
|||
);
|
||||
}
|
||||
|
||||
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(1)} ${units[i]}`;
|
||||
}
|
||||
// formatSize imported from utils
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export default function TaskManager() {
|
|||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
let interval: NodeJS.Timeout | null = null;
|
||||
|
||||
async function pollTasks() {
|
||||
try {
|
||||
|
|
@ -29,22 +29,39 @@ export default function TaskManager() {
|
|||
const activeTasks = data.tasks || [];
|
||||
setTasks(activeTasks);
|
||||
|
||||
// Auto-show if there are new active tasks
|
||||
if (activeTasks.some((t: Task) => t.status === 'running' || t.status === 'pending')) {
|
||||
setVisible(true);
|
||||
} else if (activeTasks.length === 0) {
|
||||
setVisible(false);
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// silently fail polling
|
||||
}
|
||||
}
|
||||
|
||||
// Always fetch once on mount/update
|
||||
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;
|
||||
|
||||
|
|
@ -58,9 +75,8 @@ export default function TaskManager() {
|
|||
style={{ backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center justify-between px-4 py-3 border-b transition-colors hover:bg-white/5"
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 border-b transition-colors"
|
||||
style={{ borderColor: 'var(--color-border-subtle)', backgroundColor: 'var(--color-bg-secondary)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -74,15 +90,22 @@ export default function TaskManager() {
|
|||
</span>
|
||||
</div>
|
||||
<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)' }} />}
|
||||
<div
|
||||
onClick={(e) => { e.stopPropagation(); setVisible(false); }}
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
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)' }} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task List */}
|
||||
<AnimatePresence>
|
||||
|
|
|
|||
|
|
@ -2,23 +2,20 @@ import { useState, useEffect } from 'react';
|
|||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export function useIsMobile() {
|
||||
export function useIsMobile(breakpoint = 768) {
|
||||
const [isMobile, setIsMobile] = useState<boolean>(false);
|
||||
const [hasMounted, setHasMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Initial check
|
||||
const checkIsMobile = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
setHasMounted(true);
|
||||
const mql = window.matchMedia(`(max-width: ${breakpoint}px)`);
|
||||
setIsMobile(mql.matches);
|
||||
|
||||
checkIsMobile();
|
||||
|
||||
// Add resize listener
|
||||
window.addEventListener('resize', checkIsMobile);
|
||||
|
||||
// Cleanup
|
||||
return () => window.removeEventListener('resize', checkIsMobile);
|
||||
}, []);
|
||||
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
||||
mql.addEventListener('change', handler);
|
||||
return () => mql.removeEventListener('change', handler);
|
||||
}, [breakpoint]);
|
||||
|
||||
if (!hasMounted) return false;
|
||||
return isMobile;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,6 +91,12 @@ class ApiClient {
|
|||
} catch {}
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
11
frontend/src/lib/utils.ts
Normal file
11
frontend/src/lib/utils.ts
Normal 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;
|
||||
}
|
||||
12
frontend/src/types/files.ts
Normal file
12
frontend/src/types/files.ts
Normal 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;
|
||||
}
|
||||
Reference in a new issue