Inital build
Some checks failed
Automated Container Build / build-and-push (push) Failing after 12s

This commit is contained in:
Elijah 2026-05-22 12:29:43 -07:00
parent fa2be029a2
commit 724d70e58b
3339 changed files with 1075535 additions and 0 deletions

View 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;
}

View 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
View 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} />;
}

View 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>
);
}

File diff suppressed because it is too large Load diff

View 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]}`;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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]}`;
}

View 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
View 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;