nas-samba: rebuilt as single Ubuntu image with Samba + CloudNAS (no MongoDB)
This commit is contained in:
1
apps/nas-samba/source/app/frontend/src/App.css
Normal file
1
apps/nas-samba/source/app/frontend/src/App.css
Normal file
@@ -0,0 +1 @@
|
||||
/* Additional app-specific styles */
|
||||
51
apps/nas-samba/source/app/frontend/src/App.js
Normal file
51
apps/nas-samba/source/app/frontend/src/App.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { Toaster, toast } from 'sonner';
|
||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||
import { ThemeProvider, useTheme } from './context/ThemeContext';
|
||||
import LoginPage from './components/LoginPage';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import SharePage from './components/SharePage';
|
||||
import LoadingScreen from './components/ui/LoadingScreen';
|
||||
|
||||
function AppContent() {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
// Handle /share/:token routes without React Router
|
||||
const path = window.location.pathname;
|
||||
const shareMatch = path.match(/^\/share\/([a-f0-9]+)$/);
|
||||
if (shareMatch) {
|
||||
return <SharePage token={shareMatch[1]} />;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
return <Dashboard />;
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<Toaster
|
||||
position="top-right"
|
||||
richColors
|
||||
closeButton
|
||||
toastOptions={{
|
||||
style: {
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<AppContent />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
1366
apps/nas-samba/source/app/frontend/src/components/Dashboard.js
Normal file
1366
apps/nas-samba/source/app/frontend/src/components/Dashboard.js
Normal file
File diff suppressed because it is too large
Load Diff
145
apps/nas-samba/source/app/frontend/src/components/LoginPage.js
Normal file
145
apps/nas-samba/source/app/frontend/src/components/LoginPage.js
Normal file
@@ -0,0 +1,145 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { toast } from 'sonner';
|
||||
import { Cloud, Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||
|
||||
function LoginPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!username.trim() || !password.trim()) {
|
||||
toast.error('Please enter username and password');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const user = await login(username, password);
|
||||
toast.success(`Welcome back, ${user.name}!`);
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.detail || 'Invalid credentials';
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex">
|
||||
{/* Left side - Background image */}
|
||||
<div
|
||||
className="hidden lg:flex lg:w-1/2 xl:w-3/5 relative bg-cover bg-center"
|
||||
style={{
|
||||
backgroundImage: 'url(https://images.pexels.com/photos/28428586/pexels-photo-28428586.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=650&w=940)'
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/40"></div>
|
||||
<div className="relative z-10 flex flex-col justify-end p-12 text-white">
|
||||
<h1 className="font-secondary text-4xl font-bold mb-4">CloudSync</h1>
|
||||
<p className="text-lg text-white/80 max-w-md">
|
||||
Enterprise-grade file management with secure sharing, storage quotas, and comprehensive activity tracking.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Login form */}
|
||||
<div className="flex-1 flex items-center justify-center p-8 bg-background">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo for mobile */}
|
||||
<div className="lg:hidden flex items-center gap-3 mb-8">
|
||||
<div className="w-10 h-10 bg-primary rounded-lg flex items-center justify-center">
|
||||
<Cloud className="w-5 h-5 text-primary-foreground" />
|
||||
</div>
|
||||
<span className="font-secondary text-2xl font-bold text-foreground">CloudSync</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h2 className="font-secondary text-3xl font-bold text-foreground mb-2">Sign in</h2>
|
||||
<p className="text-muted-foreground">Enter your credentials to access your files</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-2">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter your username"
|
||||
disabled={loading}
|
||||
data-testid="login-username-input"
|
||||
className="w-full h-11 px-4 rounded-lg border border-input bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-all disabled:opacity-50"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-2">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
disabled={loading}
|
||||
data-testid="login-password-input"
|
||||
className="w-full h-11 px-4 pr-12 rounded-lg border border-input bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-all disabled:opacity-50"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
data-testid="toggle-password-visibility"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
data-testid="login-submit-btn"
|
||||
className="w-full h-11 bg-primary text-primary-foreground font-medium rounded-lg hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 transition-all disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
'Sign in'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-8 p-4 bg-muted rounded-lg">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">Demo credentials:</span>
|
||||
<br />
|
||||
Username: <code className="font-mono bg-background px-1 rounded">admin</code>
|
||||
<br />
|
||||
Password: <code className="font-mono bg-background px-1 rounded">admin123</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoginPage;
|
||||
182
apps/nas-samba/source/app/frontend/src/components/SharePage.js
Normal file
182
apps/nas-samba/source/app/frontend/src/components/SharePage.js
Normal file
@@ -0,0 +1,182 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Cloud, CheckCircle, Lock, FileText, AlertCircle } from 'lucide-react';
|
||||
import { formatBytes } from '../lib/utils';
|
||||
import * as api from '../lib/api';
|
||||
|
||||
export default function SharePage({ token }) {
|
||||
const [fileInfo, setFileInfo] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [password, setPassword] = useState('');
|
||||
const [downloaded, setDownloaded] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
const doDownload = useCallback(async (pwd) => {
|
||||
if (!fileInfo || downloading) return;
|
||||
setDownloading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const url = api.downloadSharedFile(token, pwd || null);
|
||||
const res = await fetch(url, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.detail || 'Download failed');
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = fileInfo.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
setDownloaded(true);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setDownloading(false);
|
||||
}
|
||||
}, [fileInfo, downloading, token]);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const res = await api.getSharedFileInfo(token);
|
||||
setFileInfo(res.data);
|
||||
} catch (err) {
|
||||
const status = err.response?.status;
|
||||
if (status === 410) {
|
||||
setError('This share link has expired.');
|
||||
} else if (status === 404) {
|
||||
setError('Share link not found.');
|
||||
} else {
|
||||
setError('Failed to load shared file.');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [token]);
|
||||
|
||||
// Auto-download if no password required
|
||||
useEffect(() => {
|
||||
if (fileInfo && !fileInfo.requires_password && !downloaded && !downloading) {
|
||||
doDownload(null);
|
||||
}
|
||||
}, [fileInfo, downloaded, downloading, doDownload]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !fileInfo) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<div className="bg-card border border-border rounded-lg shadow-lg p-8 max-w-md w-full text-center">
|
||||
<AlertCircle className="w-12 h-12 text-destructive mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">Unavailable</h2>
|
||||
<p className="text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Downloaded state
|
||||
if (downloaded) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<div className="bg-card border border-border rounded-lg shadow-lg p-8 max-w-md w-full text-center">
|
||||
<div className="flex items-center justify-center gap-2 mb-6">
|
||||
<Cloud className="w-6 h-6 text-primary" />
|
||||
<span className="text-lg font-semibold">CloudSync</span>
|
||||
</div>
|
||||
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">File Downloaded</h2>
|
||||
<div className="flex items-center gap-3 justify-center p-3 bg-accent/50 rounded-lg mt-4">
|
||||
<FileText className="w-6 h-6 text-muted-foreground flex-shrink-0" />
|
||||
<div className="text-left min-w-0">
|
||||
<p className="font-medium text-sm truncate">{fileInfo.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatBytes(fileInfo.size)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-4">This link has been used and is no longer active.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Password required state
|
||||
if (fileInfo.requires_password && !downloaded) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<div className="bg-card border border-border rounded-lg shadow-lg p-8 max-w-md w-full">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Cloud className="w-6 h-6 text-primary" />
|
||||
<span className="text-lg font-semibold">CloudSync</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mb-6 p-4 bg-accent/50 rounded-lg">
|
||||
<FileText className="w-10 h-10 text-muted-foreground flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">{fileInfo.name}</p>
|
||||
<p className="text-sm text-muted-foreground">{formatBytes(fileInfo.size)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-destructive/10 text-destructive text-sm rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
<Lock className="w-4 h-4 inline mr-1" />
|
||||
This file is password protected
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => { setPassword(e.target.value); setError(null); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && password) doDownload(password); }}
|
||||
placeholder="Enter password"
|
||||
className="w-full h-10 px-4 bg-background border border-input rounded-lg focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => doDownload(password)}
|
||||
disabled={downloading || !password}
|
||||
className="w-full h-10 bg-primary text-primary-foreground rounded-lg hover:opacity-90 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{downloading ? (
|
||||
<div className="animate-spin w-4 h-4 border-2 border-primary-foreground border-t-transparent rounded-full"></div>
|
||||
) : (
|
||||
'Download'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Downloading state (auto-download in progress, no password)
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<div className="bg-card border border-border rounded-lg shadow-lg p-8 max-w-md w-full text-center">
|
||||
<div className="flex items-center justify-center gap-2 mb-6">
|
||||
<Cloud className="w-6 h-6 text-primary" />
|
||||
<span className="text-lg font-semibold">CloudSync</span>
|
||||
</div>
|
||||
<div className="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full mx-auto mb-4"></div>
|
||||
<p className="text-muted-foreground">Downloading {fileInfo.name}...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export function LoadingScreen() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="relative w-12 h-12">
|
||||
<div className="absolute inset-0 rounded-full border-2 border-muted"></div>
|
||||
<div className="absolute inset-0 rounded-full border-2 border-primary border-t-transparent animate-spin"></div>
|
||||
</div>
|
||||
<p className="text-muted-foreground font-medium">Loading CloudSync...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoadingScreen;
|
||||
@@ -0,0 +1,69 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { login as apiLogin, getMe } from '../lib/api';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [token, setToken] = useState(localStorage.getItem('token'));
|
||||
|
||||
const checkAuth = useCallback(async () => {
|
||||
const storedToken = localStorage.getItem('token');
|
||||
if (!storedToken) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getMe();
|
||||
setUser(response.data);
|
||||
setToken(storedToken);
|
||||
} catch (error) {
|
||||
localStorage.removeItem('token');
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, [checkAuth]);
|
||||
|
||||
const login = async (username, password) => {
|
||||
const response = await apiLogin(username, password);
|
||||
const { token: newToken, user: userData } = response.data;
|
||||
|
||||
localStorage.setItem('token', newToken);
|
||||
setToken(newToken);
|
||||
setUser(userData);
|
||||
|
||||
return userData;
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
const updateUser = useCallback((userData) => {
|
||||
setUser(prev => ({ ...prev, ...userData }));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, token, loading, login, logout, updateUser, checkAuth }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
|
||||
const ThemeContext = createContext(null);
|
||||
|
||||
export function ThemeProvider({ children }) {
|
||||
const [theme, setTheme] = useState(() => {
|
||||
const stored = localStorage.getItem('theme');
|
||||
if (stored) return stored;
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(prev => prev === 'light' ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
116
apps/nas-samba/source/app/frontend/src/index.css
Normal file
116
apps/nas-samba/source/app/frontend/src/index.css
Normal file
@@ -0,0 +1,116 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: #FFFFFF;
|
||||
--foreground: #0A0A0A;
|
||||
--card: #FFFFFF;
|
||||
--card-foreground: #0A0A0A;
|
||||
--popover: #FFFFFF;
|
||||
--popover-foreground: #0A0A0A;
|
||||
--primary: #0F172A;
|
||||
--primary-foreground: #F8FAFC;
|
||||
--secondary: #F1F5F9;
|
||||
--secondary-foreground: #0F172A;
|
||||
--muted: #F1F5F9;
|
||||
--muted-foreground: #64748B;
|
||||
--accent: #F1F5F9;
|
||||
--accent-foreground: #0F172A;
|
||||
--destructive: #EF4444;
|
||||
--destructive-foreground: #F8FAFC;
|
||||
--border: #E2E8F0;
|
||||
--input: #E2E8F0;
|
||||
--ring: #0F172A;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #020617;
|
||||
--foreground: #F8FAFC;
|
||||
--card: #0F172A;
|
||||
--card-foreground: #F8FAFC;
|
||||
--popover: #0F172A;
|
||||
--popover-foreground: #F8FAFC;
|
||||
--primary: #F8FAFC;
|
||||
--primary-foreground: #0F172A;
|
||||
--secondary: #1E293B;
|
||||
--secondary-foreground: #F8FAFC;
|
||||
--muted: #1E293B;
|
||||
--muted-foreground: #94A3B8;
|
||||
--accent: #1E293B;
|
||||
--accent-foreground: #F8FAFC;
|
||||
--destructive: #7F1D1D;
|
||||
--destructive-foreground: #F8FAFC;
|
||||
--border: #1E293B;
|
||||
--input: #1E293B;
|
||||
--ring: #D1D5DB;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--muted-foreground);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--foreground);
|
||||
}
|
||||
|
||||
/* Skeleton loader animation */
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
background: linear-gradient(90deg, var(--muted) 25%, var(--border) 50%, var(--muted) 75%);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
/* File drop zone styles */
|
||||
.drop-zone-active {
|
||||
border-color: var(--ring) !important;
|
||||
background-color: var(--accent) !important;
|
||||
}
|
||||
|
||||
/* Focus visible styles */
|
||||
.focus-visible:focus {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Transition utilities */
|
||||
.transition-default {
|
||||
transition: all 200ms ease-in-out;
|
||||
}
|
||||
11
apps/nas-samba/source/app/frontend/src/index.js
Normal file
11
apps/nas-samba/source/app/frontend/src/index.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
136
apps/nas-samba/source/app/frontend/src/lib/api.js
Normal file
136
apps/nas-samba/source/app/frontend/src/lib/api.js
Normal file
@@ -0,0 +1,136 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = process.env.REACT_APP_BACKEND_URL || '';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor to add auth token
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Response interceptor to handle auth errors
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.reload();
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Auth
|
||||
export const login = (username, password) =>
|
||||
api.post('/api/auth/login', { username, password });
|
||||
|
||||
export const getMe = () =>
|
||||
api.get('/api/auth/me');
|
||||
|
||||
// Users
|
||||
export const getUsers = () =>
|
||||
api.get('/api/users');
|
||||
|
||||
export const createUser = (userData) =>
|
||||
api.post('/api/users', userData);
|
||||
|
||||
export const updateUser = (userId, userData) =>
|
||||
api.put(`/api/users/${userId}`, userData);
|
||||
|
||||
export const deleteUser = (userId) =>
|
||||
api.delete(`/api/users/${userId}`);
|
||||
|
||||
// Files
|
||||
export const getFiles = (parentId = null, search = null) => {
|
||||
const params = new URLSearchParams();
|
||||
if (parentId) params.append('parent_id', parentId);
|
||||
if (search) params.append('search', search);
|
||||
return api.get(`/api/files?${params.toString()}`);
|
||||
};
|
||||
|
||||
export const getFile = (fileId) =>
|
||||
api.get(`/api/files/${fileId}`);
|
||||
|
||||
export const createFolder = (name, parentId = null) =>
|
||||
api.post('/api/files/folder', { name, parent_id: parentId, is_folder: true });
|
||||
|
||||
export const uploadFile = (file, parentId, onProgress) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (parentId) {
|
||||
formData.append('parent_id', parentId);
|
||||
}
|
||||
|
||||
return api.post('/api/files/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (onProgress) {
|
||||
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||
onProgress(progress);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const renameFile = (fileId, newName) =>
|
||||
api.put(`/api/files/${fileId}/rename`, { new_name: newName });
|
||||
|
||||
export const moveFile = (fileId, destinationId) =>
|
||||
api.put(`/api/files/${fileId}/move`, { destination_id: destinationId });
|
||||
|
||||
export const copyFile = (fileId, destinationId) =>
|
||||
api.post(`/api/files/${fileId}/copy`, { destination_id: destinationId });
|
||||
|
||||
export const deleteFile = (fileId) =>
|
||||
api.delete(`/api/files/${fileId}`);
|
||||
|
||||
export const bulkDeleteFiles = (fileIds) =>
|
||||
api.post('/api/files/bulk-delete', fileIds);
|
||||
|
||||
export const getDownloadUrl = (fileId) =>
|
||||
`${API_URL}/api/files/${fileId}/download`;
|
||||
|
||||
export const getPreviewUrl = (fileId) =>
|
||||
`${API_URL}/api/files/${fileId}/preview`;
|
||||
|
||||
export const getBreadcrumb = (fileId) =>
|
||||
api.get(`/api/files/${fileId}/breadcrumb`);
|
||||
|
||||
// Shares
|
||||
export const getShares = () =>
|
||||
api.get('/api/shares');
|
||||
|
||||
export const createShare = (fileId, expiresInHours = 24, password = null) =>
|
||||
api.post('/api/shares', { file_id: fileId, expires_in_hours: expiresInHours, password });
|
||||
|
||||
export const deleteShare = (shareId) =>
|
||||
api.delete(`/api/shares/${shareId}`);
|
||||
|
||||
export const getSharedFileInfo = (token) =>
|
||||
api.get(`/api/share/${token}`);
|
||||
|
||||
export const downloadSharedFile = (token, password = null) => {
|
||||
const params = password ? `?password=${encodeURIComponent(password)}` : '';
|
||||
return `${API_URL}/api/share/${token}/download${params}`;
|
||||
};
|
||||
|
||||
// Activity
|
||||
export const getActivity = (limit = 50) =>
|
||||
api.get(`/api/activity?limit=${limit}`);
|
||||
|
||||
// Stats
|
||||
export const getStats = () =>
|
||||
api.get('/api/stats');
|
||||
|
||||
export default api;
|
||||
33
apps/nas-samba/source/app/frontend/src/lib/utils.js
Normal file
33
apps/nas-samba/source/app/frontend/src/lib/utils.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatBytes(bytes, decimals = 1) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
export function formatDate(dateString) {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diff = Math.floor((now - date) / 1000);
|
||||
|
||||
if (diff < 60) return 'Just now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
|
||||
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
export function getFileExtension(filename) {
|
||||
return filename.includes('.') ? filename.split('.').pop().toLowerCase() : '';
|
||||
}
|
||||
Reference in New Issue
Block a user