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
|
# IDE & Editor files
|
||||||
.vscode/
|
.vscode/
|
||||||
.cursor/
|
.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())
|
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")
|
||||||
|
|
|
||||||
|
|
@ -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,
|
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);
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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]}`;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
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