This commit is contained in:
parent
fa2be029a2
commit
724d70e58b
3339 changed files with 1075535 additions and 0 deletions
186
frontend/src/app/globals.css
Normal file
186
frontend/src/app/globals.css
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ============================================
|
||||
DESIGN SYSTEM — Drive
|
||||
Premium dark/light theme with CSS variables
|
||||
============================================ */
|
||||
|
||||
:root {
|
||||
/* Dark theme (default) */
|
||||
--color-bg-primary: #0a0a0f;
|
||||
--color-bg-secondary: #12121a;
|
||||
--color-bg-tertiary: #1a1a26;
|
||||
--color-bg-elevated: #1e1e2e;
|
||||
--color-bg-hover: #252536;
|
||||
--color-bg-active: #2d2d42;
|
||||
--color-bg-glass: rgba(30, 30, 46, 0.7);
|
||||
|
||||
--color-border: #2a2a3d;
|
||||
--color-border-subtle: #1f1f30;
|
||||
--color-border-focus: #6366f1;
|
||||
|
||||
--color-text-primary: #e8e8ed;
|
||||
--color-text-secondary: #9ca3af;
|
||||
--color-text-tertiary: #6b7280;
|
||||
--color-text-inverse: #0a0a0f;
|
||||
|
||||
--color-accent: #6366f1;
|
||||
--color-accent-hover: #7c7ff7;
|
||||
--color-accent-subtle: rgba(99, 102, 241, 0.15);
|
||||
|
||||
--color-success: #22c55e;
|
||||
--color-warning: #f59e0b;
|
||||
--color-danger: #ef4444;
|
||||
--color-danger-hover: #dc2626;
|
||||
--color-info: #3b82f6;
|
||||
|
||||
--color-sidebar-bg: #0d0d14;
|
||||
--color-sidebar-hover: #1a1a28;
|
||||
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 24px;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
--shadow-glow: 0 0 20px rgba(99, 102, 241, 0.15);
|
||||
|
||||
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LIGHT THEME OVERRIDES
|
||||
============================================ */
|
||||
[data-theme="light"] {
|
||||
--color-bg-primary: #fafafa;
|
||||
--color-bg-secondary: #ffffff;
|
||||
--color-bg-tertiary: #f5f5f5;
|
||||
--color-bg-elevated: #ffffff;
|
||||
--color-bg-hover: #f0f0f5;
|
||||
--color-bg-active: #e8e8f0;
|
||||
--color-bg-glass: rgba(255, 255, 255, 0.8);
|
||||
|
||||
--color-border: #e2e2ea;
|
||||
--color-border-subtle: #ececf0;
|
||||
--color-border-focus: #6366f1;
|
||||
|
||||
--color-text-primary: #111118;
|
||||
--color-text-secondary: #4b5563;
|
||||
--color-text-tertiary: #9ca3af;
|
||||
--color-text-inverse: #ffffff;
|
||||
|
||||
--color-sidebar-bg: #f0f0f5;
|
||||
--color-sidebar-hover: #e4e4ec;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
--shadow-glow: 0 0 20px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
BASE STYLES
|
||||
============================================ */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Smooth transitions for theme switching */
|
||||
body,
|
||||
body * {
|
||||
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SCROLLBAR STYLING
|
||||
============================================ */
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SELECTION HIGHLIGHT
|
||||
============================================ */
|
||||
|
||||
::selection {
|
||||
background-color: var(--color-accent-subtle);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
FOCUS STYLES
|
||||
============================================ */
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-border-focus);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
UTILITY ANIMATIONS
|
||||
============================================ */
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from { opacity: 0; transform: translateX(20px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { box-shadow: var(--shadow-glow); }
|
||||
50% { box-shadow: 0 0 30px rgba(99, 102, 241, 0.3); }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slideInRight 0.3s ease-out;
|
||||
}
|
||||
29
frontend/src/app/layout.tsx
Normal file
29
frontend/src/app/layout.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Drive — Your Personal Cloud',
|
||||
description: 'A high-performance, self-hosted file storage and management platform.',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" data-theme="dark" suppressHydrationWarning>
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body className="antialiased">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
76
frontend/src/app/page.tsx
Normal file
76
frontend/src/app/page.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import api from '@/lib/api';
|
||||
import SetupWizard from '@/components/SetupWizard';
|
||||
import LoginPage from '@/components/LoginPage';
|
||||
import FileExplorer from '@/components/FileExplorer';
|
||||
|
||||
type AppState = 'loading' | 'setup' | 'login' | 'app';
|
||||
|
||||
export default function Home() {
|
||||
const [state, setState] = useState<AppState>('loading');
|
||||
|
||||
useEffect(() => {
|
||||
checkState();
|
||||
}, []);
|
||||
|
||||
async function checkState() {
|
||||
try {
|
||||
const { setup_complete } = await api.checkSetup();
|
||||
if (!setup_complete) {
|
||||
setState('setup');
|
||||
return;
|
||||
}
|
||||
|
||||
if (api.isAuthenticated()) {
|
||||
setState('app');
|
||||
} else {
|
||||
setState('login');
|
||||
}
|
||||
} catch {
|
||||
setState('login');
|
||||
}
|
||||
}
|
||||
|
||||
function handleSetupComplete() {
|
||||
setState('login');
|
||||
}
|
||||
|
||||
function handleLoginSuccess() {
|
||||
setState('app');
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
api.logout();
|
||||
setState('login');
|
||||
}
|
||||
|
||||
if (state === 'loading') {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div
|
||||
className="w-10 h-10 rounded-full border-2 border-t-transparent"
|
||||
style={{
|
||||
borderColor: 'var(--color-accent)',
|
||||
borderTopColor: 'transparent',
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
}}
|
||||
/>
|
||||
<p style={{ color: 'var(--color-text-secondary)' }}>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'setup') {
|
||||
return <SetupWizard onComplete={handleSetupComplete} />;
|
||||
}
|
||||
|
||||
if (state === 'login') {
|
||||
return <LoginPage onSuccess={handleLoginSuccess} />;
|
||||
}
|
||||
|
||||
return <FileExplorer onLogout={handleLogout} />;
|
||||
}
|
||||
149
frontend/src/app/share/[id]/page.tsx
Normal file
149
frontend/src/app/share/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
'use client';
|
||||
|
||||
import { use, useState, useEffect } from 'react';
|
||||
import { Shield, Download, Lock, File, Folder, Loader2 } from 'lucide-react';
|
||||
import api from '@/lib/api';
|
||||
|
||||
export default function PublicSharePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const resolvedParams = use(params);
|
||||
const id = resolvedParams.id;
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [requirePassword, setRequirePassword] = useState(false);
|
||||
const [password, setPassword] = useState('');
|
||||
const [fileInfo, setFileInfo] = useState<{ name: string; size: number; is_dir: boolean } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchShareInfo();
|
||||
}, []);
|
||||
|
||||
async function fetchShareInfo(pwd?: string) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.getPublicShare(id, pwd);
|
||||
if (data.require_password) {
|
||||
setRequirePassword(true);
|
||||
} else {
|
||||
setRequirePassword(false);
|
||||
setFileInfo(data);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.require_password) {
|
||||
setRequirePassword(true);
|
||||
} else {
|
||||
setError(err.error || 'Failed to load share');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePasswordSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
fetchShareInfo(password);
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
const url = api.getPublicDownloadUrl(id, password);
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
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]}`;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
|
||||
<Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--color-text-tertiary)' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
|
||||
<div className="max-w-md w-full p-8 rounded-2xl text-center shadow-xl" style={{ backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}>
|
||||
<Shield className="w-12 h-12 mx-auto mb-4" style={{ color: 'var(--color-danger)' }} />
|
||||
<h1 className="text-2xl font-bold mb-2" style={{ color: 'var(--color-text-primary)' }}>Access Denied</h1>
|
||||
<p style={{ color: 'var(--color-text-secondary)' }}>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (requirePassword) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
|
||||
<div className="max-w-md w-full p-8 rounded-2xl shadow-xl" style={{ backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}>
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4" style={{ backgroundColor: 'var(--color-accent-subtle)' }}>
|
||||
<Lock className="w-8 h-8" style={{ color: 'var(--color-accent)' }} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold mb-2" style={{ color: 'var(--color-text-primary)' }}>Protected File</h1>
|
||||
<p className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>This shared file is password protected.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handlePasswordSubmit} className="space-y-4">
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter password"
|
||||
className="w-full px-4 py-3 rounded-xl outline-none"
|
||||
style={{ backgroundColor: 'var(--color-bg-tertiary)', border: '1px solid var(--color-border)', color: 'var(--color-text-primary)' }}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-3 rounded-xl text-white font-medium transition-opacity hover:opacity-90 shadow-lg"
|
||||
style={{ background: 'linear-gradient(135deg, var(--color-accent), #8b5cf6)' }}
|
||||
>
|
||||
Unlock
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
|
||||
<div className="max-w-md w-full p-8 rounded-2xl text-center shadow-xl" style={{ backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}>
|
||||
<div className="mb-6 flex justify-center">
|
||||
{fileInfo?.is_dir ? (
|
||||
<div className="w-24 h-24 rounded-2xl flex items-center justify-center" style={{ backgroundColor: 'var(--color-accent-subtle)' }}>
|
||||
<Folder className="w-12 h-12" style={{ color: 'var(--color-accent)' }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-24 h-24 rounded-2xl flex items-center justify-center" style={{ backgroundColor: 'var(--color-accent-subtle)' }}>
|
||||
<File className="w-12 h-12" style={{ color: 'var(--color-accent)' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="text-xl font-bold mb-1 truncate px-4" style={{ color: 'var(--color-text-primary)' }} title={fileInfo?.name}>
|
||||
{fileInfo?.name}
|
||||
</h1>
|
||||
<p className="text-sm mb-8" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
{fileInfo?.is_dir ? 'Folder' : formatSize(fileInfo?.size || 0)}
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl text-white font-medium transition-opacity hover:opacity-90 shadow-lg"
|
||||
style={{ background: 'linear-gradient(135deg, var(--color-accent), #8b5cf6)' }}
|
||||
>
|
||||
<Download className="w-5 h-5" />
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1081
frontend/src/components/FileExplorer.tsx
Normal file
1081
frontend/src/components/FileExplorer.tsx
Normal file
File diff suppressed because it is too large
Load diff
239
frontend/src/components/FilePreview.tsx
Normal file
239
frontend/src/components/FilePreview.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Download, File, Image as ImageIcon, Film, Music, FileText, Code, Loader2 } from 'lucide-react';
|
||||
import api from '@/lib/api';
|
||||
|
||||
interface FilePreviewProps {
|
||||
file: {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
mime_type: string;
|
||||
} | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function FilePreview({ file, onClose }: FilePreviewProps) {
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
if (file) {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [file, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!file) return;
|
||||
|
||||
const type = getPreviewType(file);
|
||||
if (type === 'text' || type === 'code') {
|
||||
if (file.size > 2 * 1024 * 1024) { // 2MB limit for text preview
|
||||
setError('File is too large to preview. Please download it.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetch(api.getDownloadUrl(file.path))
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('Failed to load file content');
|
||||
return res.text();
|
||||
})
|
||||
.then((text) => setContent(text))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [file]);
|
||||
|
||||
if (!file) return null;
|
||||
|
||||
const previewType = getPreviewType(file);
|
||||
const downloadUrl = api.getDownloadUrl(file.path);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-8"
|
||||
style={{ backgroundColor: 'rgba(0, 0, 0, 0.8)', backdropFilter: 'blur(8px)' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0, y: 20 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.95, opacity: 0, y: 20 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="relative flex flex-col w-full max-w-5xl h-full max-h-[90vh] rounded-2xl overflow-hidden shadow-2xl"
|
||||
style={{ backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b shrink-0" style={{ borderColor: 'var(--color-border-subtle)', backgroundColor: 'var(--color-bg-secondary)' }}>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{getIcon(previewType)}
|
||||
<span className="font-medium truncate" style={{ color: 'var(--color-text-primary)' }} title={file.name}>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-1 rounded-md" style={{ backgroundColor: 'var(--color-bg-tertiary)', color: 'var(--color-text-tertiary)' }}>
|
||||
{formatSize(file.size)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download={file.name}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="p-2 rounded-lg transition-colors hover:bg-white/10"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
title="Download"
|
||||
>
|
||||
<Download className="w-5 h-5" />
|
||||
</a>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg transition-colors hover:bg-white/10"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
title="Close (Esc)"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 overflow-auto flex items-center justify-center p-4 relative" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
|
||||
{previewType === 'image' && (
|
||||
<img
|
||||
src={downloadUrl}
|
||||
alt={file.name}
|
||||
className="max-w-full max-h-full object-contain rounded-lg"
|
||||
/>
|
||||
)}
|
||||
|
||||
{previewType === 'video' && (
|
||||
<video
|
||||
src={downloadUrl}
|
||||
controls
|
||||
autoPlay
|
||||
className="max-w-full max-h-full rounded-lg outline-none"
|
||||
/>
|
||||
)}
|
||||
|
||||
{previewType === 'audio' && (
|
||||
<div className="w-full max-w-md p-6 rounded-xl text-center" style={{ backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}>
|
||||
<Music className="w-16 h-16 mx-auto mb-6" style={{ color: 'var(--color-accent)' }} />
|
||||
<h3 className="text-lg font-medium mb-4 truncate" style={{ color: 'var(--color-text-primary)' }}>{file.name}</h3>
|
||||
<audio src={downloadUrl} controls autoPlay className="w-full outline-none" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(previewType === 'text' || previewType === 'code') && (
|
||||
<div className="absolute inset-0 p-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--color-accent)' }} />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2">
|
||||
<p style={{ color: 'var(--color-danger)' }}>{error}</p>
|
||||
<a
|
||||
href={downloadUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="px-4 py-2 mt-2 rounded-lg text-sm font-medium transition-opacity hover:opacity-90"
|
||||
style={{ backgroundColor: 'var(--color-accent)', color: 'white' }}
|
||||
>
|
||||
Download File
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<pre
|
||||
className="w-full h-full p-4 overflow-auto text-sm rounded-lg"
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
backgroundColor: 'var(--color-bg-secondary)',
|
||||
color: 'var(--color-text-primary)',
|
||||
border: '1px solid var(--color-border-subtle)',
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewType === 'unsupported' && (
|
||||
<div className="flex flex-col items-center justify-center gap-4 text-center">
|
||||
<File className="w-16 h-16" style={{ color: 'var(--color-text-tertiary)' }} />
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-1" style={{ color: 'var(--color-text-primary)' }}>No Preview Available</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>This file type cannot be previewed in the browser.</p>
|
||||
</div>
|
||||
<a
|
||||
href={downloadUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2 px-6 py-2.5 mt-2 rounded-xl text-sm font-medium transition-opacity hover:opacity-90"
|
||||
style={{ backgroundColor: 'var(--color-accent)', color: 'white', boxShadow: 'var(--shadow-glow)' }}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Download File
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
type PreviewType = 'image' | 'video' | 'audio' | 'text' | 'code' | 'unsupported';
|
||||
|
||||
function getPreviewType(file: { name: string; mime_type: string }): PreviewType {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() || '';
|
||||
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext)) return 'image';
|
||||
if (['mp4', 'webm', 'ogg'].includes(ext)) return 'video'; // Only browser-supported videos
|
||||
if (['mp3', 'wav', 'flac', 'm4a', 'aac'].includes(ext)) return 'audio';
|
||||
if (['txt', 'md', 'csv', 'json', 'log'].includes(ext)) return 'text';
|
||||
if (['js', 'ts', 'jsx', 'tsx', 'html', 'css', 'go', 'py', 'rs', 'c', 'cpp', 'java', 'sh', 'yml', 'yaml', 'xml'].includes(ext)) return 'code';
|
||||
|
||||
if (file.mime_type.startsWith('image/')) return 'image';
|
||||
if (file.mime_type.startsWith('video/')) return 'video';
|
||||
if (file.mime_type.startsWith('audio/')) return 'audio';
|
||||
if (file.mime_type.startsWith('text/')) return 'text';
|
||||
|
||||
return 'unsupported';
|
||||
}
|
||||
|
||||
function getIcon(type: PreviewType) {
|
||||
const props = { className: "w-5 h-5", style: { color: 'var(--color-text-secondary)' } };
|
||||
switch (type) {
|
||||
case 'image': return <ImageIcon {...props} />;
|
||||
case 'video': return <Film {...props} />;
|
||||
case 'audio': return <Music {...props} />;
|
||||
case 'text': return <FileText {...props} />;
|
||||
case 'code': return <Code {...props} />;
|
||||
default: return <File {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
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]}`;
|
||||
}
|
||||
221
frontend/src/components/LoginPage.tsx
Normal file
221
frontend/src/components/LoginPage.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { HardDrive, Lock, User, LogIn, Shield } from 'lucide-react';
|
||||
import api from '@/lib/api';
|
||||
|
||||
interface LoginPageProps {
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export default function LoginPage({ onSuccess }: LoginPageProps) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [needs2FA, setNeeds2FA] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const { ok, status, data } = await api.login(username, password, needs2FA ? totpCode : undefined);
|
||||
|
||||
if (ok) {
|
||||
onSuccess();
|
||||
} else if (status === 428 && data.totp_required) {
|
||||
setNeeds2FA(true);
|
||||
} else {
|
||||
setError(data.error || 'Login failed');
|
||||
}
|
||||
} catch {
|
||||
setError('Connection failed. Is the server running?');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center min-h-screen p-4"
|
||||
style={{ backgroundColor: 'var(--color-bg-primary)' }}
|
||||
>
|
||||
{/* Subtle background gradient orbs */}
|
||||
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<div
|
||||
className="absolute -top-40 -right-40 w-96 h-96 rounded-full opacity-20 blur-3xl"
|
||||
style={{ background: 'radial-gradient(circle, var(--color-accent), transparent)' }}
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-40 -left-40 w-96 h-96 rounded-full opacity-10 blur-3xl"
|
||||
style={{ background: 'radial-gradient(circle, #8b5cf6, transparent)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||
className="w-full max-w-md relative z-10"
|
||||
>
|
||||
{/* Logo & Title */}
|
||||
<div className="text-center mb-8">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.2, type: 'spring', stiffness: 200 }}
|
||||
className="inline-flex items-center justify-center w-16 h-16 rounded-2xl mb-4"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, var(--color-accent), #8b5cf6)',
|
||||
boxShadow: 'var(--shadow-glow)',
|
||||
}}
|
||||
>
|
||||
<HardDrive className="w-8 h-8 text-white" />
|
||||
</motion.div>
|
||||
<h1 className="text-2xl font-bold" style={{ color: 'var(--color-text-primary)' }}>
|
||||
Welcome Back
|
||||
</h1>
|
||||
<p className="mt-2 text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Sign in to access your files
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login Card */}
|
||||
<div
|
||||
className="rounded-2xl p-8"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-bg-elevated)',
|
||||
border: '1px solid var(--color-border)',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
}}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{!needs2FA ? (
|
||||
<>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Username
|
||||
</span>
|
||||
<div className="relative mt-1.5">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
|
||||
<input
|
||||
id="login-username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Your username"
|
||||
autoFocus
|
||||
className="w-full pl-10 pr-4 py-3 rounded-xl text-sm outline-none transition-all"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-bg-tertiary)',
|
||||
border: '1px solid var(--color-border)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Password
|
||||
</span>
|
||||
<div className="relative mt-1.5">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
|
||||
<input
|
||||
id="login-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Your password"
|
||||
className="w-full pl-10 pr-4 py-3 rounded-xl text-sm outline-none transition-all"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-bg-tertiary)',
|
||||
border: '1px solid var(--color-border)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<div
|
||||
className="flex items-center gap-2 p-3 rounded-lg mb-4"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-accent-subtle)',
|
||||
border: '1px solid var(--color-accent)',
|
||||
}}
|
||||
>
|
||||
<Shield className="w-4 h-4" style={{ color: 'var(--color-accent)' }} />
|
||||
<span className="text-xs" style={{ color: 'var(--color-accent)' }}>
|
||||
Two-factor authentication required
|
||||
</span>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Authentication Code
|
||||
</span>
|
||||
<input
|
||||
id="login-totp"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
||||
placeholder="000000"
|
||||
autoFocus
|
||||
className="w-full mt-1.5 px-4 py-3 rounded-xl text-sm text-center tracking-[0.5em] font-mono outline-none transition-all"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-bg-tertiary)',
|
||||
border: '1px solid var(--color-border)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-sm px-3 py-2 rounded-lg"
|
||||
style={{
|
||||
color: 'var(--color-danger)',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</motion.p>
|
||||
)}
|
||||
|
||||
<button
|
||||
id="login-submit"
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full mt-2 py-3 rounded-xl text-sm font-semibold text-white flex items-center justify-center gap-2 transition-all duration-200 hover:opacity-90 disabled:opacity-50"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, var(--color-accent), #8b5cf6)',
|
||||
boxShadow: 'var(--shadow-glow)',
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="w-5 h-5 rounded-full border-2 border-white border-t-transparent" style={{ animation: 'spin 0.8s linear infinite' }} />
|
||||
) : (
|
||||
<>
|
||||
<LogIn className="w-4 h-4" />
|
||||
{needs2FA ? 'Verify' : 'Sign In'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
242
frontend/src/components/SettingsPage.tsx
Normal file
242
frontend/src/components/SettingsPage.tsx
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Settings, Shield, Image as ImageIcon, History, Trash2, Save, Loader2 } from 'lucide-react';
|
||||
import api from '@/lib/api';
|
||||
|
||||
interface SettingsData {
|
||||
theme: string;
|
||||
thumbnail_images: string;
|
||||
thumbnail_videos: string;
|
||||
max_versions: string;
|
||||
trash_auto_purge_days: string;
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [settings, setSettings] = useState<SettingsData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const data = await api.getSettings();
|
||||
setSettings({
|
||||
theme: data.theme || 'dark',
|
||||
thumbnail_images: data.thumbnail_images || 'true',
|
||||
thumbnail_videos: data.thumbnail_videos || 'true',
|
||||
max_versions: data.max_versions || '1',
|
||||
trash_auto_purge_days: data.trash_auto_purge_days || '14',
|
||||
});
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load settings');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!settings) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
try {
|
||||
await api.updateSettings(settings);
|
||||
setSuccess(true);
|
||||
setTimeout(() => setSuccess(false), 3000);
|
||||
|
||||
// Update local theme variable if changed
|
||||
if (settings.theme === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
document.documentElement.classList.remove('dark');
|
||||
document.documentElement.style.setProperty('color-scheme', 'light');
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
document.documentElement.classList.add('dark');
|
||||
document.documentElement.style.setProperty('color-scheme', 'dark');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to save settings');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--color-text-tertiary)' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
const sections = [
|
||||
{
|
||||
id: 'appearance',
|
||||
title: 'Appearance',
|
||||
icon: <Settings className="w-5 h-5" />,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--color-text-primary)' }}>Theme</label>
|
||||
<select
|
||||
value={settings.theme}
|
||||
onChange={(e) => setSettings({ ...settings, theme: e.target.value })}
|
||||
className="w-full max-w-xs px-3 py-2 rounded-lg text-sm outline-none"
|
||||
style={{ backgroundColor: 'var(--color-bg-primary)', border: '1px solid var(--color-border)', color: 'var(--color-text-primary)' }}
|
||||
>
|
||||
<option value="dark">Dark Mode</option>
|
||||
<option value="light">Light Mode</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'thumbnails',
|
||||
title: 'Media & Thumbnails',
|
||||
icon: <ImageIcon className="w-5 h-5" />,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.thumbnail_images === 'true'}
|
||||
onChange={(e) => setSettings({ ...settings, thumbnail_images: e.target.checked ? 'true' : 'false' })}
|
||||
className="w-4 h-4 rounded text-accent focus:ring-accent"
|
||||
style={{ accentColor: 'var(--color-accent)' }}
|
||||
/>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--color-text-primary)' }}>Generate thumbnails for images</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.thumbnail_videos === 'true'}
|
||||
onChange={(e) => setSettings({ ...settings, thumbnail_videos: e.target.checked ? 'true' : 'false' })}
|
||||
className="w-4 h-4 rounded text-accent focus:ring-accent"
|
||||
style={{ accentColor: 'var(--color-accent)' }}
|
||||
/>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--color-text-primary)' }}>Generate thumbnails for videos (requires FFmpeg)</span>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'versioning',
|
||||
title: 'File Versioning',
|
||||
icon: <History className="w-5 h-5" />,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--color-text-primary)' }}>Maximum Versions to Keep</label>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Older versions will be automatically deleted when a file is overwritten more times than this limit. Set to 0 to disable versioning.
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={settings.max_versions}
|
||||
onChange={(e) => setSettings({ ...settings, max_versions: e.target.value })}
|
||||
className="w-full max-w-xs px-3 py-2 rounded-lg text-sm outline-none"
|
||||
style={{ backgroundColor: 'var(--color-bg-primary)', border: '1px solid var(--color-border)', color: 'var(--color-text-primary)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'trash',
|
||||
title: 'Trash Auto-Purge',
|
||||
icon: <Trash2 className="w-5 h-5" />,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--color-text-primary)' }}>Days to Keep Trashed Files</label>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Files in the trash will be permanently deleted after this many days. Set to 0 to disable auto-purge.
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="365"
|
||||
value={settings.trash_auto_purge_days}
|
||||
onChange={(e) => setSettings({ ...settings, trash_auto_purge_days: e.target.value })}
|
||||
className="w-full max-w-xs px-3 py-2 rounded-lg text-sm outline-none"
|
||||
style={{ backgroundColor: 'var(--color-bg-primary)', border: '1px solid var(--color-border)', color: 'var(--color-text-primary)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto w-full">
|
||||
<div className="p-8 max-w-4xl mx-auto w-full">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2" style={{ color: 'var(--color-text-primary)' }}>Settings</h1>
|
||||
<p style={{ color: 'var(--color-text-secondary)' }}>Manage your Drive preferences and configurations.</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 rounded-xl flex items-center gap-3" style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', color: 'var(--color-danger)' }}>
|
||||
<Shield className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="mb-6 p-4 rounded-xl flex items-center gap-3" style={{ backgroundColor: 'rgba(34, 197, 94, 0.1)', color: 'var(--color-success)' }}>
|
||||
<Save className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">Settings saved successfully.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{sections.map((section, idx) => (
|
||||
<motion.div
|
||||
key={section.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: idx * 0.05 }}
|
||||
className="p-6 rounded-2xl"
|
||||
style={{ backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-6" style={{ color: 'var(--color-text-primary)' }}>
|
||||
<div className="p-2 rounded-lg" style={{ backgroundColor: 'var(--color-bg-secondary)' }}>
|
||||
{section.icon}
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold">{section.title}</h2>
|
||||
</div>
|
||||
{section.content}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div className="mt-8 flex justify-end">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-6 py-2.5 rounded-xl text-sm font-medium text-white transition-all hover:opacity-90 disabled:opacity-50"
|
||||
style={{ background: 'linear-gradient(135deg, var(--color-accent), #8b5cf6)' }}
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
258
frontend/src/components/SetupWizard.tsx
Normal file
258
frontend/src/components/SetupWizard.tsx
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { HardDrive, Lock, User, ArrowRight, Check } from 'lucide-react';
|
||||
import api from '@/lib/api';
|
||||
|
||||
interface SetupWizardProps {
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export default function SetupWizard({ onComplete }: SetupWizardProps) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [step, setStep] = useState(1);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (step === 1) {
|
||||
if (username.length < 3) {
|
||||
setError('Username must be at least 3 characters');
|
||||
return;
|
||||
}
|
||||
setStep(2);
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setError('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await api.setup(username, password);
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
} else {
|
||||
setStep(3);
|
||||
setTimeout(onComplete, 1500);
|
||||
}
|
||||
} catch {
|
||||
setError('Failed to complete setup');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center min-h-screen p-4"
|
||||
style={{ backgroundColor: 'var(--color-bg-primary)' }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||
className="w-full max-w-md"
|
||||
>
|
||||
{/* Logo & Title */}
|
||||
<div className="text-center mb-8">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.2, type: 'spring', stiffness: 200 }}
|
||||
className="inline-flex items-center justify-center w-16 h-16 rounded-2xl mb-4"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, var(--color-accent), #8b5cf6)',
|
||||
boxShadow: 'var(--shadow-glow)',
|
||||
}}
|
||||
>
|
||||
<HardDrive className="w-8 h-8 text-white" />
|
||||
</motion.div>
|
||||
<h1 className="text-2xl font-bold" style={{ color: 'var(--color-text-primary)' }}>
|
||||
Welcome to Drive
|
||||
</h1>
|
||||
<p className="mt-2 text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
{step === 3 ? 'Setup complete!' : "Let's set up your account"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Progress Indicator */}
|
||||
<div className="flex items-center justify-center gap-2 mb-8">
|
||||
{[1, 2, 3].map((s) => (
|
||||
<div
|
||||
key={s}
|
||||
className="h-1.5 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: step >= s ? '2rem' : '0.75rem',
|
||||
backgroundColor: step >= s ? 'var(--color-accent)' : 'var(--color-border)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div
|
||||
className="rounded-2xl p-8"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-bg-elevated)',
|
||||
border: '1px solid var(--color-border)',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
}}
|
||||
>
|
||||
{step === 3 ? (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="flex flex-col items-center gap-4 py-8"
|
||||
>
|
||||
<div
|
||||
className="w-16 h-16 rounded-full flex items-center justify-center"
|
||||
style={{ backgroundColor: 'rgba(34, 197, 94, 0.15)' }}
|
||||
>
|
||||
<Check className="w-8 h-8" style={{ color: 'var(--color-success)' }} />
|
||||
</div>
|
||||
<p className="font-medium" style={{ color: 'var(--color-text-primary)' }}>
|
||||
Account created successfully!
|
||||
</p>
|
||||
<p className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Redirecting to login...
|
||||
</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<motion.div
|
||||
key={step}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Username
|
||||
</span>
|
||||
<div className="relative mt-1.5">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
|
||||
<input
|
||||
id="setup-username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Choose a username"
|
||||
autoFocus
|
||||
className="w-full pl-10 pr-4 py-3 rounded-xl text-sm outline-none transition-all"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-bg-tertiary)',
|
||||
border: '1px solid var(--color-border)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Password
|
||||
</span>
|
||||
<div className="relative mt-1.5">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
|
||||
<input
|
||||
id="setup-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Create a strong password"
|
||||
autoFocus
|
||||
className="w-full pl-10 pr-4 py-3 rounded-xl text-sm outline-none transition-all"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-bg-tertiary)',
|
||||
border: '1px solid var(--color-border)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Confirm Password
|
||||
</span>
|
||||
<div className="relative mt-1.5">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
|
||||
<input
|
||||
id="setup-confirm-password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Confirm your password"
|
||||
className="w-full pl-10 pr-4 py-3 rounded-xl text-sm outline-none transition-all"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-bg-tertiary)',
|
||||
border: '1px solid var(--color-border)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{error && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mt-4 text-sm px-3 py-2 rounded-lg"
|
||||
style={{
|
||||
color: 'var(--color-danger)',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</motion.p>
|
||||
)}
|
||||
|
||||
<button
|
||||
id="setup-submit"
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full mt-6 py-3 rounded-xl text-sm font-semibold text-white flex items-center justify-center gap-2 transition-all duration-200 hover:opacity-90 disabled:opacity-50"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, var(--color-accent), #8b5cf6)',
|
||||
boxShadow: 'var(--shadow-glow)',
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="w-5 h-5 rounded-full border-2 border-white border-t-transparent" style={{ animation: 'spin 0.8s linear infinite' }} />
|
||||
) : (
|
||||
<>
|
||||
{step === 1 ? 'Continue' : 'Create Account'}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
173
frontend/src/components/ShareModal.tsx
Normal file
173
frontend/src/components/ShareModal.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Link as LinkIcon, Shield, Clock, Download, Check, Copy } from 'lucide-react';
|
||||
import api from '@/lib/api';
|
||||
|
||||
interface ShareModalProps {
|
||||
filePath: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ShareModal({ filePath, onClose }: ShareModalProps) {
|
||||
const [password, setPassword] = useState('');
|
||||
const [expiresIn, setExpiresIn] = useState(''); // hours
|
||||
const [maxDownloads, setMaxDownloads] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [shareLink, setShareLink] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleCreate() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const exp = parseInt(expiresIn, 10);
|
||||
const dl = parseInt(maxDownloads, 10);
|
||||
const res = await api.createShare(
|
||||
filePath,
|
||||
password || undefined,
|
||||
isNaN(exp) ? undefined : exp,
|
||||
isNaN(dl) ? undefined : dl
|
||||
);
|
||||
|
||||
const fullUrl = `${window.location.origin}${res.link}`;
|
||||
setShareLink(fullUrl);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to create share link');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopy() {
|
||||
if (!shareLink) return;
|
||||
navigator.clipboard.writeText(shareLink);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
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-md 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)' }}>Share File</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 space-y-5">
|
||||
<p className="text-sm truncate" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Creating public link for <strong>{filePath.split('/').pop()}</strong>
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg text-sm" style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', color: 'var(--color-danger)' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!shareLink ? (
|
||||
<>
|
||||
{/* Configuration */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium mb-1.5" style={{ color: 'var(--color-text-primary)' }}>
|
||||
<Shield className="w-4 h-4" /> Password (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Leave blank for no password"
|
||||
className="w-full px-3 py-2 rounded-lg text-sm outline-none"
|
||||
style={{ backgroundColor: 'var(--color-bg-tertiary)', border: '1px solid var(--color-border)', color: 'var(--color-text-primary)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium mb-1.5" style={{ color: 'var(--color-text-primary)' }}>
|
||||
<Clock className="w-4 h-4" /> Expires In (Hours)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={expiresIn}
|
||||
onChange={(e) => setExpiresIn(e.target.value)}
|
||||
placeholder="Never"
|
||||
className="w-full px-3 py-2 rounded-lg text-sm outline-none"
|
||||
style={{ backgroundColor: 'var(--color-bg-tertiary)', border: '1px solid var(--color-border)', color: 'var(--color-text-primary)' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium mb-1.5" style={{ color: 'var(--color-text-primary)' }}>
|
||||
<Download className="w-4 h-4" /> Max Downloads
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={maxDownloads}
|
||||
onChange={(e) => setMaxDownloads(e.target.value)}
|
||||
placeholder="Unlimited"
|
||||
className="w-full px-3 py-2 rounded-lg text-sm outline-none"
|
||||
style={{ backgroundColor: 'var(--color-bg-tertiary)', border: '1px solid var(--color-border)', color: 'var(--color-text-primary)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 rounded-xl text-sm font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-50 mt-2"
|
||||
style={{ background: 'linear-gradient(135deg, var(--color-accent), #8b5cf6)' }}
|
||||
>
|
||||
{loading ? 'Creating...' : 'Create Link'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Success View */}
|
||||
<div className="flex flex-col items-center justify-center py-4">
|
||||
<div className="w-12 h-12 rounded-full flex items-center justify-center mb-3" style={{ backgroundColor: 'rgba(34, 197, 94, 0.1)' }}>
|
||||
<Check className="w-6 h-6" style={{ color: 'var(--color-success)' }} />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-center mb-4" style={{ color: 'var(--color-text-primary)' }}>
|
||||
Share link created successfully!
|
||||
</p>
|
||||
|
||||
<div className="w-full flex items-center gap-2 p-1 rounded-lg" style={{ backgroundColor: 'var(--color-bg-tertiary)', border: '1px solid var(--color-border)' }}>
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={shareLink}
|
||||
className="flex-1 bg-transparent px-2 py-1 text-sm outline-none"
|
||||
style={{ color: 'var(--color-text-primary)' }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded text-xs font-medium transition-colors"
|
||||
style={{ backgroundColor: 'var(--color-accent)', color: 'white' }}
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
frontend/src/components/StorageDashboard.tsx
Normal file
145
frontend/src/components/StorageDashboard.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
'use client';
|
||||
|
||||
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';
|
||||
|
||||
interface CategoryUsage {
|
||||
category: string;
|
||||
size: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface StorageData {
|
||||
total_size: number;
|
||||
categories: CategoryUsage[];
|
||||
}
|
||||
|
||||
export default function StorageDashboard() {
|
||||
const [data, setData] = useState<StorageData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadStorageData();
|
||||
}, []);
|
||||
|
||||
async function loadStorageData() {
|
||||
try {
|
||||
const res = await api.getStorageDashboard();
|
||||
setData(res);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load storage data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--color-text-tertiary)' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-red-500">
|
||||
{error || 'Failed to load'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const categoryConfig: Record<string, { label: string; color: string; icon: any }> = {
|
||||
images: { label: 'Images', color: '#f59e0b', icon: ImageIcon },
|
||||
videos: { label: 'Videos', color: '#ef4444', icon: Film },
|
||||
audio: { label: 'Audio', color: '#22c55e', icon: Music },
|
||||
documents: { label: 'Documents', color: '#3b82f6', icon: FileText },
|
||||
archives: { label: 'Archives', color: '#8b5cf6', icon: Archive },
|
||||
other: { label: 'Other', color: '#9ca3af', icon: File },
|
||||
};
|
||||
|
||||
const totalDriveSize = 100 * 1024 * 1024 * 1024; // Dummy 100GB capacity for visualization, could be dynamic
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-8 max-w-5xl mx-auto w-full">
|
||||
<div className="mb-8 flex items-center gap-4">
|
||||
<div className="p-3 rounded-xl" style={{ backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}>
|
||||
<HardDrive className="w-6 h-6" style={{ color: 'var(--color-accent)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-1" style={{ color: 'var(--color-text-primary)' }}>Storage Overview</h1>
|
||||
<p style={{ color: 'var(--color-text-secondary)' }}>View and manage your drive usage</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 rounded-2xl mb-8" style={{ backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}>
|
||||
<div className="flex justify-between items-end mb-4">
|
||||
<div>
|
||||
<span className="text-4xl font-bold" style={{ color: 'var(--color-text-primary)' }}>{formatSize(data.total_size)}</span>
|
||||
<span className="text-lg ml-2" style={{ color: 'var(--color-text-secondary)' }}>used</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="h-4 w-full rounded-full overflow-hidden flex gap-0.5" style={{ backgroundColor: 'var(--color-bg-secondary)' }}>
|
||||
{data.categories.map(cat => {
|
||||
const pct = (cat.size / Math.max(data.total_size, 1)) * 100;
|
||||
if (pct === 0) return null;
|
||||
return (
|
||||
<motion.div
|
||||
key={cat.category}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${pct}%` }}
|
||||
transition={{ duration: 1, ease: 'easeOut' }}
|
||||
className="h-full"
|
||||
style={{ backgroundColor: categoryConfig[cat.category]?.color || '#9ca3af' }}
|
||||
title={`${categoryConfig[cat.category]?.label || cat.category}: ${formatSize(cat.size)}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{data.categories.map((cat, idx) => {
|
||||
const config = categoryConfig[cat.category] || categoryConfig.other;
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<motion.div
|
||||
key={cat.category}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: idx * 0.1 }}
|
||||
className="p-5 rounded-xl flex items-center gap-4"
|
||||
style={{ backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<div className="p-3 rounded-lg flex-shrink-0" style={{ backgroundColor: `${config.color}20` }}>
|
||||
<Icon className="w-6 h-6" style={{ color: config.color }} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--color-text-primary)' }}>{config.label}</p>
|
||||
<p className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>{cat.count} items</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold" style={{ color: 'var(--color-text-primary)' }}>{formatSize(cat.size)}</p>
|
||||
<p className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
{((cat.size / Math.max(data.total_size, 1)) * 100).toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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]}`;
|
||||
}
|
||||
139
frontend/src/components/TaskManager.tsx
Normal file
139
frontend/src/components/TaskManager.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Loader2, CheckCircle, XCircle, ChevronUp, ChevronDown, X } from 'lucide-react';
|
||||
import api from '@/lib/api';
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
type: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
progress: number;
|
||||
message: string;
|
||||
error?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export default function TaskManager() {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
|
||||
async function pollTasks() {
|
||||
try {
|
||||
const data = await api.getTasks();
|
||||
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);
|
||||
}
|
||||
} catch {
|
||||
// silently fail polling
|
||||
}
|
||||
}
|
||||
|
||||
pollTasks();
|
||||
interval = setInterval(pollTasks, 2000); // Poll every 2 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
if (!visible || tasks.length === 0) return null;
|
||||
|
||||
const runningCount = tasks.filter(t => t.status === 'running' || t.status === 'pending').length;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="fixed bottom-6 right-6 z-[100] w-80 shadow-2xl rounded-2xl overflow-hidden flex flex-col"
|
||||
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"
|
||||
style={{ borderColor: 'var(--color-border-subtle)', backgroundColor: 'var(--color-bg-secondary)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{runningCount > 0 ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" style={{ color: 'var(--color-accent)' }} />
|
||||
) : (
|
||||
<CheckCircle className="w-4 h-4" style={{ color: 'var(--color-success)' }} />
|
||||
)}
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--color-text-primary)' }}>
|
||||
{runningCount > 0 ? `${runningCount} active task${runningCount > 1 ? 's' : ''}` : 'All tasks completed'}
|
||||
</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); }}
|
||||
className="p-1 rounded hover:bg-white/10"
|
||||
>
|
||||
<X className="w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Task List */}
|
||||
<AnimatePresence>
|
||||
{expanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: 'auto' }}
|
||||
exit={{ height: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="max-h-60 overflow-y-auto p-2 space-y-1" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
|
||||
{tasks.map(task => (
|
||||
<div key={task.id} className="p-3 rounded-xl" style={{ backgroundColor: 'var(--color-bg-elevated)' }}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--color-text-primary)' }}>
|
||||
{task.type}
|
||||
</span>
|
||||
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
{task.progress}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-1.5 w-full rounded-full overflow-hidden mb-2" style={{ backgroundColor: 'var(--color-bg-secondary)' }}>
|
||||
<motion.div
|
||||
className="h-full rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${task.progress}%` }}
|
||||
style={{
|
||||
backgroundColor: task.status === 'failed' ? 'var(--color-danger)' : task.status === 'completed' ? 'var(--color-success)' : 'var(--color-accent)'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
{task.status === 'failed' ? (
|
||||
<XCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" style={{ color: 'var(--color-danger)' }} />
|
||||
) : task.status === 'completed' ? (
|
||||
<CheckCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" style={{ color: 'var(--color-success)' }} />
|
||||
) : (
|
||||
<Loader2 className="w-3.5 h-3.5 mt-0.5 animate-spin flex-shrink-0" style={{ color: 'var(--color-accent)' }} />
|
||||
)}
|
||||
<span className="text-xs leading-snug break-words" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
{task.status === 'failed' ? task.error : task.message}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
357
frontend/src/lib/api.ts
Normal file
357
frontend/src/lib/api.ts
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
import * as tus from 'tus-js-client';
|
||||
|
||||
interface RequestOptions {
|
||||
method?: string;
|
||||
body?: any;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private accessToken: string | null = null;
|
||||
private refreshToken: string | null = null;
|
||||
|
||||
constructor() {
|
||||
if (typeof window !== 'undefined') {
|
||||
this.accessToken = localStorage.getItem('access_token');
|
||||
this.refreshToken = localStorage.getItem('refresh_token');
|
||||
}
|
||||
}
|
||||
|
||||
setTokens(accessToken: string, refreshToken: string) {
|
||||
this.accessToken = accessToken;
|
||||
this.refreshToken = refreshToken;
|
||||
localStorage.setItem('access_token', accessToken);
|
||||
localStorage.setItem('refresh_token', refreshToken);
|
||||
}
|
||||
|
||||
clearTokens() {
|
||||
this.accessToken = null;
|
||||
this.refreshToken = null;
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
}
|
||||
|
||||
getAccessToken() {
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
isAuthenticated() {
|
||||
return !!this.accessToken;
|
||||
}
|
||||
|
||||
private async request(endpoint: string, options: RequestOptions = {}) {
|
||||
const headers: Record<string, string> = {
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
if (this.accessToken) {
|
||||
headers['Authorization'] = `Bearer ${this.accessToken}`;
|
||||
}
|
||||
|
||||
if (options.body && !(options.body instanceof FormData)) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
method: options.method || 'GET',
|
||||
headers,
|
||||
body: options.body instanceof FormData
|
||||
? options.body
|
||||
: options.body
|
||||
? JSON.stringify(options.body)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// Try to refresh token on 401
|
||||
if (response.status === 401 && this.refreshToken && endpoint !== '/api/auth/refresh') {
|
||||
const refreshed = await this.refreshAccessToken();
|
||||
if (refreshed) {
|
||||
headers['Authorization'] = `Bearer ${this.accessToken}`;
|
||||
return fetch(`${API_BASE}${endpoint}`, {
|
||||
method: options.method || 'GET',
|
||||
headers,
|
||||
body: options.body instanceof FormData
|
||||
? options.body
|
||||
: options.body
|
||||
? JSON.stringify(options.body)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async refreshAccessToken(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.refreshToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.accessToken = data.access_token;
|
||||
localStorage.setItem('access_token', data.access_token);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Refresh failed
|
||||
}
|
||||
|
||||
this.clearTokens();
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Setup ---
|
||||
async checkSetup() {
|
||||
const res = await this.request('/api/setup/status');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async setup(username: string, password: string) {
|
||||
const res = await this.request('/api/setup', {
|
||||
method: 'POST',
|
||||
body: { username, password },
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// --- Auth ---
|
||||
async login(username: string, password: string, totpCode?: string) {
|
||||
const res = await this.request('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: { username, password, totp_code: totpCode },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.access_token) {
|
||||
this.setTokens(data.access_token, data.refresh_token);
|
||||
}
|
||||
return { ok: res.ok, status: res.status, data };
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.clearTokens();
|
||||
}
|
||||
|
||||
// --- Files ---
|
||||
async listDirectory(path: string = '.') {
|
||||
const res = await this.request(`/api/files/${encodeURIComponent(path)}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async createFolder(path: string) {
|
||||
const res = await this.request('/api/files/mkdir', {
|
||||
method: 'POST',
|
||||
body: { path },
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async rename(oldPath: string, newName: string) {
|
||||
const res = await this.request('/api/files/rename', {
|
||||
method: 'POST',
|
||||
body: { old_path: oldPath, new_name: newName },
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async move(sourcePath: string, destPath: string) {
|
||||
const res = await this.request('/api/files/move', {
|
||||
method: 'POST',
|
||||
body: { source_path: sourcePath, dest_path: destPath },
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async copy(sourcePath: string, destPath: string) {
|
||||
const res = await this.request('/api/files/copy', {
|
||||
method: 'POST',
|
||||
body: { source_path: sourcePath, dest_path: destPath },
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async deleteFile(path: string) {
|
||||
const res = await this.request(`/api/files/${encodeURIComponent(path)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
upload(file: File, destPath: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const upload = new tus.Upload(file, {
|
||||
endpoint: `${API_BASE}/api/tus/`,
|
||||
retryDelays: [0, 3000, 5000, 10000, 20000],
|
||||
headers: this.getStreamHeaders(),
|
||||
metadata: {
|
||||
filename: file.name,
|
||||
filetype: file.type,
|
||||
path: destPath,
|
||||
},
|
||||
onError: function (error) {
|
||||
reject(error);
|
||||
},
|
||||
onProgress: function (bytesUploaded, bytesTotal) {
|
||||
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2);
|
||||
console.log(bytesUploaded, bytesTotal, percentage + '%');
|
||||
},
|
||||
onSuccess: function () {
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
upload.start();
|
||||
});
|
||||
}
|
||||
|
||||
getDownloadUrl(path: string) {
|
||||
const url = `${API_BASE}/api/files/download/${encodeURIComponent(path)}`;
|
||||
return this.accessToken ? `${url}?token=${this.accessToken}` : url;
|
||||
}
|
||||
|
||||
getThumbnailUrl(checksum: string) {
|
||||
const url = `${API_BASE}/api/files/thumbnail/${encodeURIComponent(checksum)}`;
|
||||
return this.accessToken ? `${url}?token=${this.accessToken}` : url;
|
||||
}
|
||||
|
||||
getStreamHeaders(): Record<string, string> {
|
||||
return this.accessToken ? { Authorization: `Bearer ${this.accessToken}` } : {};
|
||||
}
|
||||
|
||||
// --- Search ---
|
||||
async search(query: string) {
|
||||
const res = await this.request(`/api/search?q=${encodeURIComponent(query)}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// --- Pinned ---
|
||||
async listPinned() {
|
||||
const res = await this.request('/api/files/pinned');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async togglePin(path: string) {
|
||||
const res = await this.request('/api/files/pin', {
|
||||
method: 'POST',
|
||||
body: { path },
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async getTasks() {
|
||||
const res = await this.request('/api/tasks');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async getSettings() {
|
||||
const res = await this.request('/api/settings');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async updateSettings(settings: any) {
|
||||
const res = await this.request('/api/settings', {
|
||||
method: 'PUT',
|
||||
body: settings,
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async getStorageDashboard() {
|
||||
const res = await this.request('/api/storage/dashboard');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// --- Trash ---
|
||||
async listTrash() {
|
||||
const res = await this.request('/api/trash');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async restoreFromTrash(path: string) {
|
||||
const res = await this.request('/api/trash/restore', {
|
||||
method: 'POST',
|
||||
body: { path },
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async emptyTrash() {
|
||||
const res = await this.request('/api/trash', { method: 'DELETE' });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
||||
|
||||
// --- Audit ---
|
||||
async getAuditLog(limit = 100, offset = 0) {
|
||||
const res = await this.request(`/api/audit?limit=${limit}&offset=${offset}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// --- 2FA ---
|
||||
async enable2FA() {
|
||||
const res = await this.request('/api/auth/2fa/enable', { method: 'POST' });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async confirm2FA(code: string) {
|
||||
const res = await this.request('/api/auth/2fa/confirm', {
|
||||
method: 'POST',
|
||||
body: { code },
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async disable2FA() {
|
||||
const res = await this.request('/api/auth/2fa/disable', { method: 'POST' });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// --- Sharing ---
|
||||
async createShare(path: string, password?: string, expiresIn?: number, maxDownloads?: number) {
|
||||
const res = await this.request('/api/share', {
|
||||
method: 'POST',
|
||||
body: { path, password, expires_in: expiresIn, max_downloads: maxDownloads },
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async listShares() {
|
||||
const res = await this.request('/api/share');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async revokeShare(id: string) {
|
||||
const res = await this.request(`/api/share/${id}`, { method: 'DELETE' });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async getPublicShare(id: string, password?: string) {
|
||||
// Note: No auth headers here, so we use fetch directly to avoid interceptors
|
||||
const res = await fetch(`${API_BASE}/api/public/share/${id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw err;
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
getPublicDownloadUrl(id: string, password?: string) {
|
||||
let url = `${API_BASE}/api/public/download/${id}`;
|
||||
if (password) {
|
||||
url += `?pwd=${encodeURIComponent(password)}`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient();
|
||||
export default api;
|
||||
Reference in a new issue