nas-samba: rebuilt as single Ubuntu image with Samba + CloudNAS (no MongoDB)

This commit is contained in:
2026-03-16 23:22:42 +00:00
parent 0104827234
commit 618e023996
43 changed files with 26653 additions and 107 deletions

View File

@@ -0,0 +1,13 @@
{
"files": {
"main.css": "/static/css/main.da1289f2.css",
"main.js": "/static/js/main.fcd0e5c7.js",
"index.html": "/index.html",
"main.da1289f2.css.map": "/static/css/main.da1289f2.css.map",
"main.fcd0e5c7.js.map": "/static/js/main.fcd0e5c7.js.map"
},
"entrypoints": [
"static/css/main.da1289f2.css",
"static/js/main.fcd0e5c7.js"
]
}

View File

@@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#0F172A"/><meta name="description" content="CloudSync - Enterprise File Manager"/><title>CloudSync - Enterprise File Manager</title><script defer="defer" src="/static/js/main.fcd0e5c7.js"></script><link href="/static/css/main.da1289f2.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,46 @@
/**
* @license React
* react-dom.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react-jsx-runtime.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* scheduler.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license lucide-react v0.312.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
{
"name": "cloudsync-frontend",
"version": "2.0.0",
"private": true,
"dependencies": {
"axios": "^1.6.5",
"clsx": "^2.1.1",
"date-fns": "^3.3.0",
"lucide-react": "^0.312.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#0F172A" />
<meta name="description" content="CloudSync - Enterprise File Manager" />
<title>CloudSync - Enterprise File Manager</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

View File

@@ -0,0 +1 @@
/* Additional app-specific styles */

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

File diff suppressed because it is too large Load Diff

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

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

View File

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

View File

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

View File

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

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

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

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

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

View File

@@ -0,0 +1,60 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{js,jsx,ts,tsx}"],
darkMode: 'class',
theme: {
extend: {
colors: {
background: "var(--background)",
foreground: "var(--foreground)",
card: "var(--card)",
"card-foreground": "var(--card-foreground)",
popover: "var(--popover)",
"popover-foreground": "var(--popover-foreground)",
primary: "var(--primary)",
"primary-foreground": "var(--primary-foreground)",
secondary: "var(--secondary)",
"secondary-foreground": "var(--secondary-foreground)",
muted: "var(--muted)",
"muted-foreground": "var(--muted-foreground)",
accent: "var(--accent)",
"accent-foreground": "var(--accent-foreground)",
destructive: "var(--destructive)",
"destructive-foreground": "var(--destructive-foreground)",
border: "var(--border)",
input: "var(--input)",
ring: "var(--ring)",
},
fontFamily: {
primary: ['Inter', 'system-ui', 'sans-serif'],
secondary: ['Manrope', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
animation: {
'fade-in': 'fadeIn 0.3s ease-out',
'slide-up': 'slideUp 0.3s ease-out',
'slide-down': 'slideDown 0.3s ease-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { opacity: '0', transform: 'translateY(10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
slideDown: {
'0%': { opacity: '0', transform: 'translateY(-10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
},
},
},
plugins: [],
};

2558
apps/nas-samba/source/app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
{
"name": "cloudsync",
"version": "1.0.0",
"description": "CloudSync File Manager - NAS Edition",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"dependencies": {
"archiver": "^7.0.1",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.7.0",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"mime-types": "^2.1.35",
"multer": "^1.4.5-lts.1"
}
}

File diff suppressed because it is too large Load Diff