first commit
This commit is contained in:
commit
ec0038d4cf
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
release/
|
||||||
|
.vite/
|
||||||
|
*.log
|
||||||
|
*.exe
|
||||||
|
*.exe.blockmap
|
||||||
|
*.blockmap
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
28
README.md
Normal file
28
README.md
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# Metatron.Drive Desktop
|
||||||
|
|
||||||
|
Windows desktop client for the Metatron.Drive private file workspace.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- Electron
|
||||||
|
- React
|
||||||
|
- Tailwind CSS
|
||||||
|
- JavaScript only
|
||||||
|
- Vite
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production build
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run dist
|
||||||
|
```
|
||||||
|
|
||||||
|
The Windows installer is generated in the `release` folder.
|
||||||
|
|
||||||
|
The production API is configured in `src/lib/api.js`.
|
||||||
13
electron/launch.cjs
Normal file
13
electron/launch.cjs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
const { spawn } = require('node:child_process')
|
||||||
|
const electronPath = require('electron')
|
||||||
|
|
||||||
|
const env = { ...process.env }
|
||||||
|
delete env.ELECTRON_RUN_AS_NODE
|
||||||
|
|
||||||
|
const child = spawn(electronPath, ['.'], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env,
|
||||||
|
stdio: 'inherit',
|
||||||
|
})
|
||||||
|
|
||||||
|
child.on('exit', (code) => process.exit(code ?? 0))
|
||||||
82
electron/main.cjs
Normal file
82
electron/main.cjs
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
const { app, BrowserWindow, ipcMain, safeStorage, dialog, Notification, shell } = require('electron')
|
||||||
|
const path = require('node:path')
|
||||||
|
const fs = require('node:fs/promises')
|
||||||
|
|
||||||
|
const isDev = !app.isPackaged
|
||||||
|
|
||||||
|
app.setName('Metatron.Drive')
|
||||||
|
app.setAppUserModelId('metatron.drive')
|
||||||
|
|
||||||
|
function tokenPath() {
|
||||||
|
return path.join(app.getPath('userData'), 'session.bin')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createWindow() {
|
||||||
|
const window = new BrowserWindow({
|
||||||
|
width: 1280,
|
||||||
|
height: 820,
|
||||||
|
minWidth: 980,
|
||||||
|
minHeight: 650,
|
||||||
|
show: false,
|
||||||
|
backgroundColor: '#f6f8fc',
|
||||||
|
title: 'Metatron.Drive',
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, 'preload.cjs'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
window.once('ready-to-show', () => window.show())
|
||||||
|
if (isDev) await window.loadURL('http://localhost:5173')
|
||||||
|
else await window.loadFile(path.join(__dirname, '..', 'dist', 'index.html'))
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
ipcMain.handle('session:read', async () => {
|
||||||
|
try {
|
||||||
|
const encrypted = await fs.readFile(tokenPath())
|
||||||
|
return safeStorage.isEncryptionAvailable()
|
||||||
|
? safeStorage.decryptString(encrypted)
|
||||||
|
: encrypted.toString('utf8')
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('session:write', async (_, token) => {
|
||||||
|
const payload = safeStorage.isEncryptionAvailable()
|
||||||
|
? safeStorage.encryptString(token)
|
||||||
|
: Buffer.from(token, 'utf8')
|
||||||
|
await fs.mkdir(app.getPath('userData'), { recursive: true })
|
||||||
|
await fs.writeFile(tokenPath(), payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('session:clear', async () => {
|
||||||
|
await fs.rm(tokenPath(), { force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('file:save', async (_, { name, bytes }) => {
|
||||||
|
const result = await dialog.showSaveDialog({ defaultPath: name })
|
||||||
|
if (result.canceled || !result.filePath) return false
|
||||||
|
await fs.writeFile(result.filePath, Buffer.from(bytes))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('notification:show', (_, { title, body }) => {
|
||||||
|
if (Notification.isSupported()) new Notification({ title, body }).show()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('shell:openExternal', (_, url) => shell.openExternal(url))
|
||||||
|
|
||||||
|
createWindow()
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') app.quit()
|
||||||
|
})
|
||||||
12
electron/preload.cjs
Normal file
12
electron/preload.cjs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
const { contextBridge, ipcRenderer } = require('electron')
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('desktop', {
|
||||||
|
session: {
|
||||||
|
read: () => ipcRenderer.invoke('session:read'),
|
||||||
|
write: (token) => ipcRenderer.invoke('session:write', token),
|
||||||
|
clear: () => ipcRenderer.invoke('session:clear'),
|
||||||
|
},
|
||||||
|
saveFile: (name, bytes) => ipcRenderer.invoke('file:save', { name, bytes }),
|
||||||
|
notify: (title, body) => ipcRenderer.invoke('notification:show', { title, body }),
|
||||||
|
openExternal: (url) => ipcRenderer.invoke('shell:openExternal', url),
|
||||||
|
})
|
||||||
35
eslint.config.js
Normal file
35
eslint.config.js
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import react from 'eslint-plugin-react'
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{ ignores: ['dist', 'release', 'node_modules'] },
|
||||||
|
{
|
||||||
|
files: ['src/**/*.{js,jsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
globals: { ...globals.browser },
|
||||||
|
parserOptions: { ecmaVersion: 'latest', ecmaFeatures: { jsx: true }, sourceType: 'module' },
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
react,
|
||||||
|
},
|
||||||
|
settings: { react: { version: 'detect' } },
|
||||||
|
rules: {
|
||||||
|
...js.configs.recommended.rules,
|
||||||
|
...reactHooks.configs.flat.recommended.rules,
|
||||||
|
...reactRefresh.configs.vite.rules,
|
||||||
|
...react.configs.recommended.rules,
|
||||||
|
'react/react-in-jsx-scope': 'off',
|
||||||
|
'react/prop-types': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['electron/**/*.cjs'],
|
||||||
|
languageOptions: { ecmaVersion: 2022, globals: { ...globals.node }, sourceType: 'commonjs' },
|
||||||
|
},
|
||||||
|
]
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#101d42" />
|
||||||
|
<title>Metatron.Drive</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
9288
package-lock.json
generated
Normal file
9288
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
66
package.json
Normal file
66
package.json
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"name": "metatron.drive",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Metatron.Drive Windows desktop client",
|
||||||
|
"main": "electron/main.cjs",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently -k \"vite\" \"wait-on tcp:5173 && node electron/launch.cjs\"",
|
||||||
|
"build": "vite build",
|
||||||
|
"start": "node electron/launch.cjs",
|
||||||
|
"dist": "npm run build && electron-builder --win nsis",
|
||||||
|
"lint": "eslint ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"lucide-react": "^0.554.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"tailwindcss": "^4.1.17"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
|
"electron": "^39.2.3",
|
||||||
|
"electron-builder": "^26.0.12",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react": "^7.37.5",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"vite": "^7.2.4",
|
||||||
|
"wait-on": "^9.0.3"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "metatron.drive",
|
||||||
|
"productName": "Metatron.Drive",
|
||||||
|
"asar": true,
|
||||||
|
"directories": {
|
||||||
|
"output": "release"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist/**/*",
|
||||||
|
"electron/**/*",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"win": {
|
||||||
|
"target": [
|
||||||
|
{
|
||||||
|
"target": "nsis",
|
||||||
|
"arch": [
|
||||||
|
"x64"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"allowToChangeInstallationDirectory": true,
|
||||||
|
"createDesktopShortcut": true,
|
||||||
|
"createStartMenuShortcut": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/App.jsx
Normal file
37
src/App.jsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { api } from './lib/api.js'
|
||||||
|
import Splash from './components/Splash.jsx'
|
||||||
|
import AuthScreen from './components/AuthScreen.jsx'
|
||||||
|
import DriveApp from './components/DriveApp.jsx'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [status, setStatus] = useState('loading')
|
||||||
|
const [user, setUser] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
const minimumSplash = new Promise((resolve) => setTimeout(resolve, 1000))
|
||||||
|
Promise.all([window.desktop.session.read(), minimumSplash]).then(async ([token]) => {
|
||||||
|
if (!active) return
|
||||||
|
if (!token) return setStatus('signedOut')
|
||||||
|
try {
|
||||||
|
const profile = await api.me()
|
||||||
|
if (active) { setUser(profile); setStatus('signedIn') }
|
||||||
|
} catch {
|
||||||
|
await window.desktop.session.clear()
|
||||||
|
if (active) setStatus('signedOut')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => { active = false }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await window.desktop.session.clear()
|
||||||
|
setUser(null)
|
||||||
|
setStatus('signedOut')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'loading') return <Splash />
|
||||||
|
if (status === 'signedOut') return <AuthScreen onAuthenticated={(profile) => { setUser(profile); setStatus('signedIn') }} />
|
||||||
|
return <DriveApp user={user} onLogout={logout} />
|
||||||
|
}
|
||||||
127
src/components/AuthScreen.jsx
Normal file
127
src/components/AuthScreen.jsx
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { ArrowLeft, Cloud, Eye, EyeOff, LockKeyhole, Mail, Phone, User, UserRound } from 'lucide-react'
|
||||||
|
import { api, errorMessage } from '../lib/api.js'
|
||||||
|
|
||||||
|
const emptyRegister = { name: '', username: '', email: '', phone: '', password: '' }
|
||||||
|
|
||||||
|
export default function AuthScreen({ onAuthenticated }) {
|
||||||
|
const [mode, setMode] = useState('login')
|
||||||
|
const [login, setLogin] = useState({ username: '', password: '' })
|
||||||
|
const [register, setRegister] = useState(emptyRegister)
|
||||||
|
const [hidden, setHidden] = useState(true)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function submitLogin(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (login.username.trim().length < 3 || !login.password) return setError('Enter your username and password.')
|
||||||
|
setBusy(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const result = await api.login(login.username.trim(), login.password)
|
||||||
|
await window.desktop.session.write(result.token)
|
||||||
|
onAuthenticated(result.user)
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(errorMessage(requestError))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitRegister(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
const username = register.username.trim().toLowerCase()
|
||||||
|
if (register.name.trim().length < 2) return setError('Enter your full name.')
|
||||||
|
if (!/^[a-z0-9._-]{3,30}$/.test(username)) return setError('Enter a valid username.')
|
||||||
|
if (!/^.+@.+\..+$/.test(register.email.trim())) return setError('Enter a valid email address.')
|
||||||
|
if (register.phone.trim() && register.phone.trim().length < 7) return setError('Phone must have at least 7 characters or be blank.')
|
||||||
|
if (register.password.length < 8) return setError('Password must contain at least 8 characters.')
|
||||||
|
setBusy(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const result = await api.register({
|
||||||
|
name: register.name.trim(),
|
||||||
|
username,
|
||||||
|
email: register.email.trim().toLowerCase(),
|
||||||
|
phone: register.phone.trim() || `wd${Date.now()}`,
|
||||||
|
age: 18,
|
||||||
|
gender: 'Prefer not to say',
|
||||||
|
password: register.password,
|
||||||
|
})
|
||||||
|
await window.desktop.session.write(result.token)
|
||||||
|
onAuthenticated(result.user)
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(errorMessage(requestError))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="grid h-screen grid-cols-[1.05fr_0.95fr] bg-[#f6f8fc]">
|
||||||
|
<section className="relative flex overflow-hidden bg-[#101d42] p-14 text-white">
|
||||||
|
<div className="absolute -left-32 -top-32 h-96 w-96 rounded-full bg-blue-600/20" />
|
||||||
|
<div className="absolute -bottom-40 right-0 h-[30rem] w-[30rem] rounded-full bg-emerald-400/10" />
|
||||||
|
<div className="relative z-10 flex max-w-xl flex-col justify-between">
|
||||||
|
<div className="flex items-center gap-3 text-xl font-black">
|
||||||
|
<span className="flex h-11 w-11 items-center justify-center rounded-2xl bg-blue-600"><Cloud size={25} fill="currentColor" /></span>
|
||||||
|
Metatron.Drive
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-4 text-sm font-bold uppercase tracking-[0.25em] text-emerald-300">Private organizational storage</p>
|
||||||
|
<h1 className="text-5xl font-black leading-[1.08]">Your files.<br />Your workspace.<br />Always protected.</h1>
|
||||||
|
<p className="mt-6 max-w-lg text-lg leading-8 text-white/65">Upload and organize full-quality files in your dedicated Google Drive workspace from Windows.</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-white/40">Metatroncube Digital Family</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="scrollbar flex h-screen items-center justify-center overflow-y-auto px-12 py-10">
|
||||||
|
{mode === 'login' ? (
|
||||||
|
<form className="w-full max-w-md" onSubmit={submitLogin}>
|
||||||
|
<h2 className="text-3xl font-black text-slate-900">Welcome back</h2>
|
||||||
|
<p className="mt-2 text-slate-500">Sign in to open your private drive.</p>
|
||||||
|
<div className="mt-9 space-y-4">
|
||||||
|
<Field icon={UserRound} label="Username" value={login.username} onChange={(value) => setLogin({ ...login, username: value })} autoFocus />
|
||||||
|
<PasswordField value={login.password} onChange={(value) => setLogin({ ...login, password: value })} hidden={hidden} setHidden={setHidden} />
|
||||||
|
</div>
|
||||||
|
{error && <ErrorBox message={error} />}
|
||||||
|
<button className="btn-primary mt-6 w-full" disabled={busy}>{busy ? <Spinner /> : 'Sign in'}</button>
|
||||||
|
<button type="button" className="mt-5 w-full text-sm font-bold text-blue-600 hover:text-blue-700" onClick={() => { setError(''); setMode('register') }}>New to the family? Create an account</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form className="w-full max-w-lg" onSubmit={submitRegister}>
|
||||||
|
<button type="button" className="mb-5 inline-flex items-center gap-2 text-sm font-bold text-slate-500 hover:text-blue-600" onClick={() => { setError(''); setMode('login') }}><ArrowLeft size={17} /> Back to sign in</button>
|
||||||
|
<h2 className="text-3xl font-black text-slate-900">Join the digital family</h2>
|
||||||
|
<p className="mt-2 text-slate-500">We create your private Google Drive folder automatically.</p>
|
||||||
|
<div className="mt-7 grid grid-cols-2 gap-4">
|
||||||
|
<div className="col-span-2"><Field icon={User} label="Full name" value={register.name} onChange={(value) => setRegister({ ...register, name: value })} autoFocus /></div>
|
||||||
|
<Field icon={UserRound} label="Username" value={register.username} onChange={(value) => setRegister({ ...register, username: value })} />
|
||||||
|
<Field icon={Mail} type="email" label="Email address" value={register.email} onChange={(value) => setRegister({ ...register, email: value })} />
|
||||||
|
<div className="col-span-2"><Field icon={Phone} label="Phone number (optional)" value={register.phone} onChange={(value) => setRegister({ ...register, phone: value })} /></div>
|
||||||
|
<div className="col-span-2"><PasswordField value={register.password} onChange={(value) => setRegister({ ...register, password: value })} hidden={hidden} setHidden={setHidden} /></div>
|
||||||
|
</div>
|
||||||
|
{error && <ErrorBox message={error} />}
|
||||||
|
<button className="btn-primary mt-6 w-full" disabled={busy}>{busy ? <Spinner /> : 'Create Metatron.Drive'}</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ icon: Icon, label, value, onChange, type = 'text', autoFocus = false }) {
|
||||||
|
return <label className="relative block"><Icon className="absolute left-4 top-3.5 text-slate-400" size={19} /><input className="field pl-11" type={type} placeholder={label} value={value} onChange={(event) => onChange(event.target.value)} autoFocus={autoFocus} /></label>
|
||||||
|
}
|
||||||
|
|
||||||
|
function PasswordField({ value, onChange, hidden, setHidden }) {
|
||||||
|
return <label className="relative block"><LockKeyhole className="absolute left-4 top-3.5 text-slate-400" size={19} /><input className="field px-11" type={hidden ? 'password' : 'text'} placeholder="Password" value={value} onChange={(event) => onChange(event.target.value)} /><button type="button" className="absolute right-3 top-2.5 icon-btn h-8 w-8" onClick={() => setHidden(!hidden)}>{hidden ? <Eye size={18} /> : <EyeOff size={18} />}</button></label>
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorBox({ message }) {
|
||||||
|
return <div className="mt-5 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-700">{message}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function Spinner() {
|
||||||
|
return <span className="h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||||
|
}
|
||||||
271
src/components/DriveApp.jsx
Normal file
271
src/components/DriveApp.jsx
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
ArrowLeft, CheckCircle2, ChevronRight, Cloud, CloudUpload, Download,
|
||||||
|
File, FileArchive, FileAudio, FileText, FileVideo, Folder, FolderPlus, HardDrive,
|
||||||
|
History, Image, LoaderCircle, LogOut, MoreHorizontal, Plus, RefreshCw, Search,
|
||||||
|
ShieldCheck, Trash2, Upload, XCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { api, errorMessage } from '../lib/api.js'
|
||||||
|
import { dateTime, fileSize, initials } from '../lib/format.js'
|
||||||
|
import { useUploads } from '../hooks/useUploads.js'
|
||||||
|
import Modal from './Modal.jsx'
|
||||||
|
import UploadHistory from './UploadHistory.jsx'
|
||||||
|
|
||||||
|
export default function DriveApp({ user, onLogout }) {
|
||||||
|
const [path, setPath] = useState([])
|
||||||
|
const [folders, setFolders] = useState([])
|
||||||
|
const [files, setFiles] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [view, setView] = useState('drive')
|
||||||
|
const [modal, setModal] = useState(null)
|
||||||
|
const [toast, setToast] = useState(null)
|
||||||
|
const inputRef = useRef(null)
|
||||||
|
const folderId = path.at(-1)?.id || null
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const [nextFolders, nextFiles] = await Promise.all([api.folders(folderId), api.files(folderId)])
|
||||||
|
setFolders(nextFolders)
|
||||||
|
setFiles(nextFiles)
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(errorMessage(requestError))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [folderId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(load, 0)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [load])
|
||||||
|
useEffect(() => {
|
||||||
|
if (!toast) return undefined
|
||||||
|
const timer = setTimeout(() => setToast(null), 3500)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [toast])
|
||||||
|
|
||||||
|
const onUploadComplete = useCallback((destinationId) => {
|
||||||
|
if ((destinationId || null) === folderId) load()
|
||||||
|
}, [folderId, load])
|
||||||
|
const uploadState = useUploads(onUploadComplete)
|
||||||
|
|
||||||
|
async function createFolder(name) {
|
||||||
|
try {
|
||||||
|
await api.createFolder(name, folderId)
|
||||||
|
setModal(null)
|
||||||
|
setToast({ type: 'success', message: `Folder “${name}” created.` })
|
||||||
|
load()
|
||||||
|
} catch (requestError) {
|
||||||
|
setToast({ type: 'error', message: errorMessage(requestError) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFolder(folder) {
|
||||||
|
if (!window.confirm(`Delete the empty folder “${folder.name}”?`)) return
|
||||||
|
try {
|
||||||
|
await api.deleteFolder(folder.id)
|
||||||
|
setToast({ type: 'success', message: 'Folder deleted.' })
|
||||||
|
load()
|
||||||
|
} catch (requestError) {
|
||||||
|
setToast({ type: 'error', message: errorMessage(requestError) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFile(item) {
|
||||||
|
if (!window.confirm(`Delete “${item.originalName}” from Google Drive?`)) return
|
||||||
|
try {
|
||||||
|
await api.deleteFile(item.id)
|
||||||
|
setToast({ type: 'success', message: 'File deleted.' })
|
||||||
|
load()
|
||||||
|
} catch (requestError) {
|
||||||
|
setToast({ type: 'error', message: errorMessage(requestError) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFile(item) {
|
||||||
|
try {
|
||||||
|
const bytes = await api.content(item.id, 'arraybuffer')
|
||||||
|
await window.desktop.saveFile(item.originalName, new Uint8Array(bytes))
|
||||||
|
} catch (requestError) {
|
||||||
|
setToast({ type: 'error', message: errorMessage(requestError) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectFiles(event) {
|
||||||
|
const selected = Array.from(event.target.files || [])
|
||||||
|
if (selected.length) {
|
||||||
|
uploadState.enqueue(selected, folderId)
|
||||||
|
setToast({ type: 'success', message: `${selected.length} file${selected.length === 1 ? '' : 's'} added to uploads.` })
|
||||||
|
}
|
||||||
|
event.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const needle = search.trim().toLowerCase()
|
||||||
|
const shownFolders = folders.filter((item) => item.name.toLowerCase().includes(needle))
|
||||||
|
const shownFiles = files.filter((item) => item.originalName.toLowerCase().includes(needle))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex h-screen bg-[#f6f8fc]">
|
||||||
|
<Sidebar user={user} view={view} setView={setView} activeUploads={uploadState.active.length} onProfile={() => setModal('profile')} />
|
||||||
|
<section className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<header className="flex h-[82px] shrink-0 items-center gap-5 border-b border-slate-200 bg-white px-7">
|
||||||
|
{view === 'drive' && path.length > 0 && <button className="icon-btn" onClick={() => setPath((current) => current.slice(0, -1))}><ArrowLeft size={21} /></button>}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h1 className="truncate text-xl font-black text-slate-900">{view === 'history' ? 'Upload history' : path.at(-1)?.name || 'Metatron.Drive'}</h1>
|
||||||
|
<p className="mt-0.5 text-xs text-slate-500">{view === 'history' ? 'Track every desktop upload' : `Hello, ${user.name.split(' ')[0]}`}</p>
|
||||||
|
</div>
|
||||||
|
{view === 'drive' && <>
|
||||||
|
<label className="relative w-72"><Search className="absolute left-3.5 top-2.5 text-slate-400" size={18} /><input className="field py-2 pl-10" placeholder="Search this folder" value={search} onChange={(event) => setSearch(event.target.value)} /></label>
|
||||||
|
<button className="icon-btn" title="Refresh" onClick={load}><RefreshCw size={20} /></button>
|
||||||
|
<button className="btn-secondary" onClick={() => setModal('folder')}><FolderPlus size={18} /> New folder</button>
|
||||||
|
<button className="btn-primary py-2.5" onClick={() => inputRef.current?.click()}><Upload size={18} /> Upload files</button>
|
||||||
|
<input ref={inputRef} className="hidden" type="file" multiple onChange={selectFiles} />
|
||||||
|
</>}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{view === 'history' ? (
|
||||||
|
<UploadHistory state={uploadState} />
|
||||||
|
) : (
|
||||||
|
<div className="scrollbar flex-1 overflow-y-auto p-7 pb-28">
|
||||||
|
<DriveBanner />
|
||||||
|
<Breadcrumb path={path} setPath={setPath} />
|
||||||
|
{loading ? <LoadingState /> : error ? <ErrorState message={error} retry={load} /> : (
|
||||||
|
<>
|
||||||
|
{shownFolders.length > 0 && <DriveSection title="Folders" count={shownFolders.length}>
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-4">
|
||||||
|
{shownFolders.map((folder) => <FolderCard key={folder.id} folder={folder} onOpen={() => setPath((current) => [...current, folder])} onDelete={() => deleteFolder(folder)} />)}
|
||||||
|
</div>
|
||||||
|
</DriveSection>}
|
||||||
|
{shownFiles.length > 0 && <DriveSection title="Files" count={shownFiles.length}>
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(210px,1fr))] gap-4">
|
||||||
|
{shownFiles.map((item) => <FileCard key={item.id} item={item} onPreview={() => setModal({ type: 'preview', item })} onDownload={() => downloadFile(item)} onDelete={() => deleteFile(item)} />)}
|
||||||
|
</div>
|
||||||
|
</DriveSection>}
|
||||||
|
{shownFolders.length === 0 && shownFiles.length === 0 && <EmptyDrive search={needle} upload={() => inputRef.current?.click()} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{uploadState.active.length > 0 && <ActiveUploadBar items={uploadState.active} onOpen={() => setView('history')} onCancel={uploadState.cancel} />}
|
||||||
|
{toast && <Toast toast={toast} />}
|
||||||
|
{modal === 'folder' && <NewFolderModal onClose={() => setModal(null)} onCreate={createFolder} />}
|
||||||
|
{modal === 'profile' && <ProfileModal user={user} onClose={() => setModal(null)} onLogout={onLogout} onDeleted={onLogout} setToast={setToast} />}
|
||||||
|
{modal?.type === 'preview' && <PreviewModal item={modal.item} onClose={() => setModal(null)} onDownload={() => downloadFile(modal.item)} />}
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sidebar({ user, view, setView, activeUploads, onProfile }) {
|
||||||
|
return <aside className="flex w-64 shrink-0 flex-col border-r border-slate-200 bg-white px-4 py-5">
|
||||||
|
<div className="flex items-center gap-3 px-3 text-lg font-black"><span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-blue-600 text-white"><Cloud size={23} fill="currentColor" /></span>Metatron.Drive</div>
|
||||||
|
<nav className="mt-9 space-y-1">
|
||||||
|
<NavButton active={view === 'drive'} icon={HardDrive} label="My Drive" onClick={() => setView('drive')} />
|
||||||
|
<NavButton active={view === 'history'} icon={History} label="Upload history" badge={activeUploads || null} onClick={() => setView('history')} />
|
||||||
|
</nav>
|
||||||
|
<div className="mt-8 rounded-2xl bg-[#101d42] p-4 text-white">
|
||||||
|
<ShieldCheck className="text-emerald-300" size={23} />
|
||||||
|
<p className="mt-3 text-sm font-bold">Private workspace</p>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-white/55">Original quality files with protected access.</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={onProfile} className="mt-auto flex items-center gap-3 rounded-2xl border border-slate-200 p-3 text-left transition hover:bg-slate-50">
|
||||||
|
<span className="flex h-10 w-10 items-center justify-center rounded-xl bg-[#101d42] text-sm font-black text-white">{initials(user.name)}</span>
|
||||||
|
<span className="min-w-0 flex-1"><span className="block truncate text-sm font-bold text-slate-800">{user.name}</span><span className="block truncate text-xs text-slate-500">@{user.username}</span></span>
|
||||||
|
<MoreHorizontal size={18} className="text-slate-400" />
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavButton({ active, icon: Icon, label, badge, onClick }) {
|
||||||
|
return <button onClick={onClick} className={`flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm font-bold transition ${active ? 'bg-blue-50 text-blue-700' : 'text-slate-500 hover:bg-slate-50 hover:text-slate-800'}`}><Icon size={20} /><span className="flex-1 text-left">{label}</span>{badge && <span className="rounded-full bg-blue-600 px-2 py-0.5 text-[10px] text-white">{badge}</span>}</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
function DriveBanner() {
|
||||||
|
return <div className="mb-6 flex items-center gap-4 rounded-2xl border border-blue-100 bg-gradient-to-r from-blue-50 to-emerald-50/50 px-5 py-4"><span className="flex h-11 w-11 items-center justify-center rounded-xl bg-white text-blue-600 shadow-sm"><ShieldCheck size={23} /></span><div><p className="font-extrabold text-slate-800">Private workspace</p><p className="text-sm text-slate-500">Original quality • protected access • synchronized with Google Drive</p></div></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function Breadcrumb({ path, setPath }) {
|
||||||
|
return <div className="mb-6 flex items-center gap-1 text-sm font-semibold text-slate-500"><button className="rounded-lg px-2 py-1 hover:bg-white hover:text-blue-600" onClick={() => setPath([])}>Metatron.Drive</button>{path.map((folder, index) => <span key={folder.id} className="flex min-w-0 items-center"><ChevronRight size={15} /><button className="max-w-48 truncate rounded-lg px-2 py-1 hover:bg-white hover:text-blue-600" onClick={() => setPath((current) => current.slice(0, index + 1))}>{folder.name}</button></span>)}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function DriveSection({ title, count, children }) {
|
||||||
|
return <section className="mb-8"><div className="mb-3 flex items-center gap-2"><h2 className="font-extrabold text-slate-800">{title}</h2><span className="text-xs font-bold text-slate-400">{count}</span></div>{children}</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
function FolderCard({ folder, onOpen, onDelete }) {
|
||||||
|
return <article className="card group flex items-center gap-4 p-4 transition hover:-translate-y-0.5 hover:border-blue-200 hover:shadow-md"><button className="flex min-w-0 flex-1 items-center gap-4 text-left" onDoubleClick={onOpen} onClick={onOpen}><span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-amber-50 text-amber-500"><Folder size={29} fill="currentColor" /></span><span className="min-w-0"><span className="block truncate text-sm font-extrabold text-slate-800">{folder.name}</span><span className="mt-1 block text-xs text-slate-400">{folder._count?.children || 0} folders • {folder._count?.files || 0} files</span></span></button><button className="icon-btn opacity-0 group-hover:opacity-100" title="Delete empty folder" onClick={onDelete}><Trash2 size={17} /></button></article>
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileCard({ item, onPreview, onDownload, onDelete }) {
|
||||||
|
const [menu, setMenu] = useState(false)
|
||||||
|
return <article className="card group overflow-hidden transition hover:-translate-y-0.5 hover:border-blue-200 hover:shadow-md">
|
||||||
|
<button className="flex h-36 w-full items-center justify-center bg-slate-50" onDoubleClick={onPreview} onClick={onPreview}>{item.mimeType.startsWith('image/') ? <AuthenticatedImage id={item.id} alt={item.originalName} /> : <FileTypeIcon type={item.mimeType} />}</button>
|
||||||
|
<div className="relative flex items-center gap-2 p-3"><div className="min-w-0 flex-1"><p className="truncate text-sm font-bold text-slate-800" title={item.originalName}>{item.originalName}</p><p className="mt-1 text-xs text-slate-400">{fileSize(item.sizeBytes)}</p></div><button className="icon-btn h-8 w-8" onClick={() => setMenu(!menu)}><MoreHorizontal size={18} /></button>{menu && <div className="absolute bottom-11 right-3 z-20 w-36 rounded-xl border border-slate-200 bg-white p-1 shadow-xl"><MenuButton icon={Download} label="Download" onClick={() => { setMenu(false); onDownload() }} /><MenuButton icon={Trash2} label="Delete" danger onClick={() => { setMenu(false); onDelete() }} /></div>}</div>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthenticatedImage({ id, alt }) {
|
||||||
|
const [source, setSource] = useState(null)
|
||||||
|
useEffect(() => {
|
||||||
|
let url
|
||||||
|
let active = true
|
||||||
|
api.content(id).then((blob) => { if (active) { url = URL.createObjectURL(blob); setSource(url) } }).catch(() => {})
|
||||||
|
return () => { active = false; if (url) URL.revokeObjectURL(url) }
|
||||||
|
}, [id])
|
||||||
|
return source ? <img src={source} alt={alt} className="h-full w-full object-cover" /> : <LoaderCircle className="animate-spin text-blue-400" size={28} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuButton({ icon: Icon, label, onClick, danger = false }) {
|
||||||
|
return <button className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-xs font-bold hover:bg-slate-50 ${danger ? 'text-red-600' : 'text-slate-600'}`} onClick={onClick}><Icon size={15} />{label}</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileTypeIcon({ type = '' }) {
|
||||||
|
const props = { size: 48, className: 'text-blue-500' }
|
||||||
|
if (type.startsWith('video/')) return <FileVideo {...props} />
|
||||||
|
if (type.startsWith('audio/')) return <FileAudio {...props} />
|
||||||
|
if (type.includes('pdf') || type.includes('document') || type.startsWith('text/')) return <FileText {...props} />
|
||||||
|
if (type.includes('zip') || type.includes('compressed') || type.includes('archive')) return <FileArchive {...props} />
|
||||||
|
if (type.startsWith('image/')) return <Image {...props} />
|
||||||
|
return <File {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingState() { return <div className="flex h-64 items-center justify-center"><LoaderCircle className="animate-spin text-blue-600" size={34} /></div> }
|
||||||
|
function ErrorState({ message, retry }) { return <div className="card flex h-64 flex-col items-center justify-center text-center"><XCircle className="text-red-500" size={42} /><h3 className="mt-4 font-extrabold">Could not load your drive</h3><p className="mt-2 text-sm text-slate-500">{message}</p><button className="btn-secondary mt-5" onClick={retry}><RefreshCw size={17} /> Try again</button></div> }
|
||||||
|
function EmptyDrive({ search, upload }) { return <div className="card flex h-72 flex-col items-center justify-center text-center"><CloudUpload className="text-blue-500" size={54} /><h3 className="mt-4 text-lg font-extrabold">{search ? 'No matching files' : 'This folder is empty'}</h3><p className="mt-2 text-sm text-slate-500">{search ? 'Try another search term.' : 'Upload files or create a new folder to get started.'}</p>{!search && <button className="btn-primary mt-5 py-2.5" onClick={upload}><Plus size={18} /> Upload files</button>}</div> }
|
||||||
|
|
||||||
|
function ActiveUploadBar({ items, onOpen, onCancel }) {
|
||||||
|
const item = items[0]
|
||||||
|
return <div className="fixed bottom-5 right-6 z-30 w-[390px] rounded-2xl bg-[#101d42] p-4 text-white shadow-2xl"><div className="flex items-center gap-3"><CloudUpload className="text-emerald-300" size={23} /><button className="min-w-0 flex-1 text-left" onClick={onOpen}><p className="truncate text-sm font-bold">{items.length} active upload{items.length === 1 ? '' : 's'}</p><p className="mt-1 truncate text-xs text-white/50">{item.name}</p></button><button className="icon-btn h-8 w-8 text-white/60 hover:bg-white/10 hover:text-white" onClick={() => onCancel(item.id)}><XCircle size={18} /></button></div><div className="mt-3 h-1.5 overflow-hidden rounded-full bg-white/15"><div className="h-full rounded-full bg-emerald-400 transition-all" style={{ width: `${Math.round(item.progress * 100)}%` }} /></div></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function Toast({ toast }) { return <div className={`fixed right-6 top-24 z-[60] flex max-w-md items-center gap-3 rounded-xl border bg-white px-4 py-3 text-sm font-bold shadow-xl ${toast.type === 'error' ? 'border-red-200 text-red-700' : 'border-emerald-200 text-emerald-700'}`}>{toast.type === 'error' ? <XCircle size={19} /> : <CheckCircle2 size={19} />}{toast.message}</div> }
|
||||||
|
|
||||||
|
function NewFolderModal({ onClose, onCreate }) {
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
return <Modal title="New folder" onClose={onClose}><form onSubmit={(event) => { event.preventDefault(); if (name.trim()) onCreate(name.trim()) }}><label className="text-sm font-bold text-slate-600">Folder name</label><input className="field mt-2" value={name} onChange={(event) => setName(event.target.value)} autoFocus maxLength={100} /><div className="mt-6 flex justify-end gap-3"><button type="button" className="btn-secondary" onClick={onClose}>Cancel</button><button className="btn-primary py-2.5" disabled={!name.trim()}><FolderPlus size={18} /> Create folder</button></div></form></Modal>
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileModal({ user, onClose, onLogout, onDeleted, setToast }) {
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
async function deleteAccount(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
try { await api.deleteAccount(password); await window.desktop.session.clear(); onDeleted() } catch (requestError) { setToast({ type: 'error', message: errorMessage(requestError) }) }
|
||||||
|
}
|
||||||
|
return <Modal title="Profile" onClose={onClose}><div className="flex flex-col items-center text-center"><span className="flex h-20 w-20 items-center justify-center rounded-3xl bg-[#101d42] text-2xl font-black text-white">{initials(user.name)}</span><h3 className="mt-4 text-xl font-black">{user.name}</h3><p className="mt-1 text-sm text-slate-500">@{user.username} • {user.email}</p></div><button className="btn-secondary mt-7 w-full" onClick={onLogout}><LogOut size={18} /> Sign out</button>{!deleting ? <button className="mt-4 w-full text-sm font-bold text-red-600" onClick={() => setDeleting(true)}>Delete database account</button> : <form className="mt-5 rounded-xl border border-red-200 bg-red-50 p-4 text-left" onSubmit={deleteAccount}><p className="text-sm font-bold text-red-800">Confirm account deletion</p><p className="mt-1 text-xs leading-5 text-red-700">Database records and logs will be deleted. Google Drive files remain untouched.</p><input className="field mt-3" type="password" placeholder="Current password" value={password} onChange={(event) => setPassword(event.target.value)} /><button className="mt-3 w-full rounded-xl bg-red-600 px-4 py-2.5 text-sm font-bold text-white" disabled={!password}>Delete account</button></form>}</Modal>
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewModal({ item, onClose, onDownload }) {
|
||||||
|
const [source, setSource] = useState(null)
|
||||||
|
useEffect(() => {
|
||||||
|
let url
|
||||||
|
api.content(item.id).then((blob) => { url = URL.createObjectURL(blob); setSource(url) }).catch(() => {})
|
||||||
|
return () => { if (url) URL.revokeObjectURL(url) }
|
||||||
|
}, [item.id])
|
||||||
|
return <Modal title={item.originalName} onClose={onClose} width="max-w-4xl"><div className="flex min-h-80 items-center justify-center overflow-hidden rounded-xl bg-slate-950">{!source ? <LoaderCircle className="animate-spin text-white" size={34} /> : item.mimeType.startsWith('image/') ? <img className="max-h-[60vh] max-w-full object-contain" src={source} alt={item.originalName} /> : item.mimeType.startsWith('video/') ? <video className="max-h-[60vh] max-w-full" src={source} controls autoPlay /> : item.mimeType.startsWith('audio/') ? <audio src={source} controls autoPlay /> : <File size={72} className="text-white/60" />}</div><div className="mt-4 flex items-center justify-between"><div><p className="text-sm font-bold">{fileSize(item.sizeBytes)}</p><p className="mt-1 text-xs text-slate-500">Uploaded {dateTime(item.createdAt)}</p></div><button className="btn-primary py-2.5" onClick={onDownload}><Download size={18} /> Download</button></div></Modal>
|
||||||
|
}
|
||||||
15
src/components/Modal.jsx
Normal file
15
src/components/Modal.jsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { X } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function Modal({ title, children, onClose, width = 'max-w-md' }) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/45 p-6 backdrop-blur-sm" onMouseDown={onClose}>
|
||||||
|
<section className={`card w-full ${width} max-h-[88vh] overflow-hidden`} onMouseDown={(event) => event.stopPropagation()}>
|
||||||
|
<header className="flex items-center justify-between border-b border-slate-100 px-6 py-4">
|
||||||
|
<h2 className="text-lg font-extrabold">{title}</h2>
|
||||||
|
<button className="icon-btn" onClick={onClose} aria-label="Close"><X size={20} /></button>
|
||||||
|
</header>
|
||||||
|
<div className="scrollbar max-h-[calc(88vh-72px)] overflow-y-auto p-6">{children}</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
30
src/components/Splash.jsx
Normal file
30
src/components/Splash.jsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { CloudUpload } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function Splash() {
|
||||||
|
return (
|
||||||
|
<main className="relative flex h-screen items-center justify-center overflow-hidden bg-[#101d42] text-white">
|
||||||
|
{Array.from({ length: 16 }, (_, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="splash-dot absolute rounded-full opacity-15"
|
||||||
|
style={{
|
||||||
|
width: 22 + (index % 5) * 13,
|
||||||
|
height: 22 + (index % 5) * 13,
|
||||||
|
left: `${5 + ((index * 19) % 90)}%`,
|
||||||
|
top: `${7 + ((index * 27) % 84)}%`,
|
||||||
|
background: index % 2 ? '#34d6a0' : '#2b59ff',
|
||||||
|
animationDelay: `${index * -0.3}s`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<section className="relative z-10 flex flex-col items-center text-center">
|
||||||
|
<div className="mb-7 flex h-24 w-24 items-center justify-center rounded-[28px] bg-white/10 ring-1 ring-white/15">
|
||||||
|
<CloudUpload size={48} />
|
||||||
|
</div>
|
||||||
|
<p className="mb-2 text-lg text-white/65">Welcome to</p>
|
||||||
|
<h1 className="text-4xl font-black leading-tight">Metatroncube<br />Digital Family</h1>
|
||||||
|
<div className="mt-9 h-7 w-7 animate-spin rounded-full border-2 border-white/20 border-t-[#34d6a0]" />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
67
src/components/UploadHistory.jsx
Normal file
67
src/components/UploadHistory.jsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { CheckCircle2, Clock3, CloudUpload, LoaderCircle, RefreshCw, Trash2, XCircle } from 'lucide-react'
|
||||||
|
import { dateTime, fileSize } from '../lib/format.js'
|
||||||
|
import Modal from './Modal.jsx'
|
||||||
|
|
||||||
|
const visuals = {
|
||||||
|
queued: { label: 'Queued', color: 'text-blue-600', bg: 'bg-blue-50', bar: 'bg-blue-600', icon: Clock3 },
|
||||||
|
uploading: { label: 'Uploading', color: 'text-blue-600', bg: 'bg-blue-50', bar: 'bg-blue-600', icon: CloudUpload },
|
||||||
|
complete: { label: 'Uploaded', color: 'text-emerald-600', bg: 'bg-emerald-50', bar: 'bg-emerald-500', icon: CheckCircle2 },
|
||||||
|
failed: { label: 'Failed', color: 'text-red-600', bg: 'bg-red-50', bar: 'bg-red-500', icon: XCircle },
|
||||||
|
canceled: { label: 'Canceled', color: 'text-slate-500', bg: 'bg-slate-100', bar: 'bg-slate-400', icon: XCircle },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UploadHistory({ state }) {
|
||||||
|
const [selected, setSelected] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const active = state.uploads.filter((item) => !['complete', 'failed', 'canceled'].includes(item.status)).length
|
||||||
|
const done = state.uploads.filter((item) => item.status === 'complete').length
|
||||||
|
const failed = state.uploads.filter((item) => item.status === 'failed').length
|
||||||
|
|
||||||
|
function retry(id) {
|
||||||
|
try { state.retry(id); setError('') } catch (retryError) { setError(retryError.message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="scrollbar flex-1 overflow-y-auto p-7">
|
||||||
|
<div className="mb-6 grid max-w-3xl grid-cols-3 gap-4">
|
||||||
|
<Summary value={active} label="Active" tone="blue" />
|
||||||
|
<Summary value={done} label="Completed" tone="green" />
|
||||||
|
<Summary value={failed} label="Failed" tone="red" />
|
||||||
|
</div>
|
||||||
|
<div className="mb-4 flex items-center justify-between"><div><h2 className="font-extrabold text-slate-800">All uploads</h2><p className="mt-1 text-xs text-slate-500">History is stored locally on this Windows computer.</p></div>{state.uploads.some((item) => ['complete', 'failed', 'canceled'].includes(item.status)) && <button className="btn-secondary" onClick={state.clearFinished}><Trash2 size={17} /> Clear finished</button>}</div>
|
||||||
|
{error && <div className="mb-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-700">{error}</div>}
|
||||||
|
{state.uploads.length === 0 ? <EmptyHistory /> : <div className="space-y-3">{state.uploads.map((item) => <UploadRow key={item.id} item={item} onOpen={() => setSelected(item)} onCancel={() => state.cancel(item.id)} onRetry={() => retry(item.id)} onRemove={() => state.remove(item.id)} />)}</div>}
|
||||||
|
{selected && <UploadDetail item={state.uploads.find((item) => item.id === selected.id) || selected} onClose={() => setSelected(null)} onCancel={() => state.cancel(selected.id)} onRetry={() => retry(selected.id)} />}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function Summary({ value, label, tone }) {
|
||||||
|
const colors = { blue: 'text-blue-600 bg-blue-50', green: 'text-emerald-600 bg-emerald-50', red: 'text-red-600 bg-red-50' }
|
||||||
|
return <div className="card flex items-center gap-4 p-4"><span className={`flex h-12 w-12 items-center justify-center rounded-xl text-xl font-black ${colors[tone]}`}>{value}</span><div><p className="text-sm font-extrabold text-slate-800">{label}</p><p className="mt-0.5 text-xs text-slate-400">upload{value === 1 ? '' : 's'}</p></div></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function UploadRow({ item, onOpen, onCancel, onRetry, onRemove }) {
|
||||||
|
const visual = visuals[item.status] || visuals.queued
|
||||||
|
const Icon = visual.icon
|
||||||
|
const final = ['complete', 'failed', 'canceled'].includes(item.status)
|
||||||
|
return <article className="card flex items-center gap-4 p-4 transition hover:border-blue-200 hover:shadow-md">
|
||||||
|
<button className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-xl ${visual.bg} ${visual.color}`} onClick={onOpen}>{item.status === 'uploading' ? <LoaderCircle className="animate-spin" size={22} /> : <Icon size={22} />}</button>
|
||||||
|
<button className="min-w-0 flex-1 text-left" onClick={onOpen}><div className="flex items-center justify-between gap-3"><p className="truncate text-sm font-extrabold text-slate-800">{item.name}</p><span className={`text-xs font-bold ${visual.color}`}>{visual.label}</span></div><div className="mt-2 h-1.5 overflow-hidden rounded-full bg-slate-100"><div className={`h-full rounded-full transition-all ${visual.bar}`} style={{ width: `${Math.round(item.progress * 100)}%` }} /></div><div className="mt-2 flex gap-3 text-xs text-slate-400"><span>{fileSize(item.size)}</span><span>•</span><span>{dateTime(item.createdAt)}</span><span className="ml-auto">{Math.round(item.progress * 100)}%</span></div></button>
|
||||||
|
{!final && <button className="icon-btn" title="Cancel" onClick={onCancel}><XCircle size={18} /></button>}
|
||||||
|
{item.status === 'failed' && <button className="icon-btn" title="Retry" onClick={onRetry}><RefreshCw size={18} /></button>}
|
||||||
|
{final && <button className="icon-btn" title="Remove history" onClick={onRemove}><Trash2 size={18} /></button>}
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyHistory() {
|
||||||
|
return <div className="card flex h-72 flex-col items-center justify-center text-center"><Clock3 className="text-blue-500" size={52} /><h3 className="mt-4 text-lg font-extrabold">No uploads yet</h3><p className="mt-2 text-sm text-slate-500">Uploads and detailed progress will appear here.</p></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function UploadDetail({ item, onClose, onCancel, onRetry }) {
|
||||||
|
const visual = visuals[item.status] || visuals.queued
|
||||||
|
const Icon = visual.icon
|
||||||
|
const final = ['complete', 'failed', 'canceled'].includes(item.status)
|
||||||
|
return <Modal title="Upload details" onClose={onClose}><div className="flex flex-col items-center text-center"><span className={`flex h-16 w-16 items-center justify-center rounded-2xl ${visual.bg} ${visual.color}`}><Icon size={31} /></span><h3 className="mt-4 max-w-full truncate text-lg font-black">{item.name}</h3><p className={`mt-1 text-sm font-bold ${visual.color}`}>{visual.label}</p></div><div className="mt-6 h-2 overflow-hidden rounded-full bg-slate-100"><div className={`h-full rounded-full ${visual.bar}`} style={{ width: `${Math.round(item.progress * 100)}%` }} /></div><p className="mt-2 text-center text-sm font-bold">{Math.round(item.progress * 100)}%</p><dl className="mt-6 divide-y divide-slate-100 rounded-xl border border-slate-200 px-4"><Detail label="Started" value={dateTime(item.createdAt)} /><Detail label="File size" value={fileSize(item.size)} /><Detail label="Status" value={visual.label} /><Detail label="Task ID" value={item.id} />{item.error && <Detail label="Error" value={item.error} />}</dl>{!final && <button className="btn-secondary mt-5 w-full" onClick={onCancel}><XCircle size={18} /> Cancel upload</button>}{item.status === 'failed' && <button className="btn-primary mt-5 w-full" onClick={onRetry}><RefreshCw size={18} /> Retry upload</button>}</Modal>
|
||||||
|
}
|
||||||
|
|
||||||
|
function Detail({ label, value }) { return <div className="grid grid-cols-[90px_1fr] gap-3 py-3 text-left text-sm"><dt className="text-slate-400">{label}</dt><dd className="break-all font-semibold text-slate-700">{value}</dd></div> }
|
||||||
102
src/hooks/useUploads.js
Normal file
102
src/hooks/useUploads.js
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||||
|
import { api, errorMessage } from '../lib/api.js'
|
||||||
|
|
||||||
|
const HISTORY_KEY = 'metatron.drive-upload-history-v1'
|
||||||
|
const finalStatuses = new Set(['complete', 'failed', 'canceled'])
|
||||||
|
|
||||||
|
function readHistory() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]')
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploads(onComplete) {
|
||||||
|
const [uploads, setUploadsState] = useState(readHistory)
|
||||||
|
const fileRefs = useRef(new Map())
|
||||||
|
const abortRefs = useRef(new Map())
|
||||||
|
|
||||||
|
const setUploads = useCallback((updater) => {
|
||||||
|
setUploadsState((current) => {
|
||||||
|
const next = typeof updater === 'function' ? updater(current) : updater
|
||||||
|
localStorage.setItem(HISTORY_KEY, JSON.stringify(next.slice(0, 200)))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const update = useCallback((id, patch) => {
|
||||||
|
setUploads((current) => current.map((item) => item.id === id ? { ...item, ...patch } : item))
|
||||||
|
}, [setUploads])
|
||||||
|
|
||||||
|
const run = useCallback(async (entry, file) => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
abortRefs.current.set(entry.id, controller)
|
||||||
|
update(entry.id, { status: 'uploading', error: null })
|
||||||
|
try {
|
||||||
|
await api.upload(file, entry.folderId, {
|
||||||
|
signal: controller.signal,
|
||||||
|
onProgress: (progress) => update(entry.id, { progress: Math.max(0, Math.min(1, progress)) }),
|
||||||
|
})
|
||||||
|
update(entry.id, { status: 'complete', progress: 1, finishedAt: new Date().toISOString() })
|
||||||
|
window.desktop.notify('Upload complete', `${entry.name} is now in Metatron.Drive`)
|
||||||
|
onComplete?.(entry.folderId)
|
||||||
|
} catch (requestError) {
|
||||||
|
const canceled = requestError?.code === 'ERR_CANCELED'
|
||||||
|
update(entry.id, {
|
||||||
|
status: canceled ? 'canceled' : 'failed',
|
||||||
|
error: canceled ? 'Canceled by user' : errorMessage(requestError),
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
if (!canceled) window.desktop.notify('Upload failed', `Could not upload ${entry.name}`)
|
||||||
|
} finally {
|
||||||
|
abortRefs.current.delete(entry.id)
|
||||||
|
}
|
||||||
|
}, [onComplete, update])
|
||||||
|
|
||||||
|
const enqueue = useCallback((files, folderId) => {
|
||||||
|
for (const file of files) {
|
||||||
|
const id = `${Date.now()}-${crypto.randomUUID()}`
|
||||||
|
const entry = {
|
||||||
|
id,
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
type: file.type || 'application/octet-stream',
|
||||||
|
folderId: folderId || null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
status: 'queued',
|
||||||
|
progress: 0,
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
fileRefs.current.set(id, file)
|
||||||
|
setUploads((current) => [entry, ...current])
|
||||||
|
run(entry, file)
|
||||||
|
}
|
||||||
|
}, [run, setUploads])
|
||||||
|
|
||||||
|
const cancel = useCallback((id) => abortRefs.current.get(id)?.abort(), [])
|
||||||
|
|
||||||
|
const retry = useCallback((id) => {
|
||||||
|
const file = fileRefs.current.get(id)
|
||||||
|
const original = uploads.find((item) => item.id === id)
|
||||||
|
if (!file || !original) throw new Error('Select the original file again to retry after restarting the app.')
|
||||||
|
const retryEntry = { ...original, id: `${Date.now()}-${crypto.randomUUID()}`, createdAt: new Date().toISOString(), status: 'queued', progress: 0, error: null, finishedAt: null }
|
||||||
|
fileRefs.current.set(retryEntry.id, file)
|
||||||
|
setUploads((current) => [retryEntry, ...current])
|
||||||
|
run(retryEntry, file)
|
||||||
|
}, [run, setUploads, uploads])
|
||||||
|
|
||||||
|
const remove = useCallback((id) => {
|
||||||
|
abortRefs.current.get(id)?.abort()
|
||||||
|
abortRefs.current.delete(id)
|
||||||
|
fileRefs.current.delete(id)
|
||||||
|
setUploads((current) => current.filter((item) => item.id !== id))
|
||||||
|
}, [setUploads])
|
||||||
|
|
||||||
|
const clearFinished = useCallback(() => {
|
||||||
|
setUploads((current) => current.filter((item) => !finalStatuses.has(item.status)))
|
||||||
|
}, [setUploads])
|
||||||
|
|
||||||
|
const active = useMemo(() => uploads.filter((item) => !finalStatuses.has(item.status)), [uploads])
|
||||||
|
return { uploads, active, enqueue, cancel, retry, remove, clearFinished }
|
||||||
|
}
|
||||||
63
src/lib/api.js
Normal file
63
src/lib/api.js
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
export const API_BASE_URL = 'https://filedriveapi.thedomainnest.com/api'
|
||||||
|
|
||||||
|
const client = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
timeout: 5 * 60 * 1000,
|
||||||
|
})
|
||||||
|
|
||||||
|
client.interceptors.request.use(async (config) => {
|
||||||
|
const token = await window.desktop.session.read()
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
export function errorMessage(error) {
|
||||||
|
return error?.response?.data?.error || error?.message || 'Something went wrong. Please try again.'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
async login(username, password) {
|
||||||
|
return (await client.post('/auth/login', { username, password })).data
|
||||||
|
},
|
||||||
|
async register(input) {
|
||||||
|
return (await client.post('/auth/register', input)).data
|
||||||
|
},
|
||||||
|
async me() {
|
||||||
|
return (await client.get('/auth/me')).data.user
|
||||||
|
},
|
||||||
|
async folders(parentId) {
|
||||||
|
return (await client.get('/folders', { params: parentId ? { parentId } : undefined })).data.folders
|
||||||
|
},
|
||||||
|
async files(folderId) {
|
||||||
|
return (await client.get('/files', { params: folderId ? { folderId } : undefined })).data.files
|
||||||
|
},
|
||||||
|
createFolder(name, parentId) {
|
||||||
|
return client.post('/folders', { name, parentId })
|
||||||
|
},
|
||||||
|
deleteFolder(id) {
|
||||||
|
return client.delete(`/folders/${id}`)
|
||||||
|
},
|
||||||
|
deleteFile(id) {
|
||||||
|
return client.delete(`/files/${id}`)
|
||||||
|
},
|
||||||
|
deleteAccount(password) {
|
||||||
|
return client.delete('/retention/account', { data: { password } })
|
||||||
|
},
|
||||||
|
async content(id, responseType = 'blob') {
|
||||||
|
return (await client.get(`/files/${id}/content`, { responseType })).data
|
||||||
|
},
|
||||||
|
upload(file, folderId, { onProgress, signal } = {}) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
if (folderId) form.append('folderId', folderId)
|
||||||
|
return client.post('/files/upload', form, {
|
||||||
|
signal,
|
||||||
|
onUploadProgress: (event) => {
|
||||||
|
const total = event.total || file.size
|
||||||
|
onProgress?.(total ? event.loaded / total : 0)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
24
src/lib/format.js
Normal file
24
src/lib/format.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
export function fileSize(bytes) {
|
||||||
|
if (!Number.isFinite(Number(bytes)) || Number(bytes) < 0) return 'Unknown size'
|
||||||
|
const value = Number(bytes)
|
||||||
|
if (value < 1024) return `${value} B`
|
||||||
|
if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KB`
|
||||||
|
if (value < 1024 ** 3) return `${(value / 1024 ** 2).toFixed(1)} MB`
|
||||||
|
return `${(value / 1024 ** 3).toFixed(2)} GB`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dateTime(value) {
|
||||||
|
return new Intl.DateTimeFormat('en-IN', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(new Date(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initials(name = '') {
|
||||||
|
return name
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0].toUpperCase())
|
||||||
|
.join('') || 'M'
|
||||||
|
}
|
||||||
10
src/main.jsx
Normal file
10
src/main.jsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import App from './App.jsx'
|
||||||
|
import './styles.css'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
28
src/styles.css
Normal file
28
src/styles.css
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
color: #101d42;
|
||||||
|
background: #f6f8fc;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body, #root { min-height: 100%; margin: 0; }
|
||||||
|
body { min-width: 980px; min-height: 650px; overflow: hidden; }
|
||||||
|
button, input { font: inherit; }
|
||||||
|
button { cursor: pointer; }
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.card { @apply rounded-2xl border border-slate-200 bg-white shadow-sm; }
|
||||||
|
.field { @apply w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-500 focus:ring-4 focus:ring-blue-100; }
|
||||||
|
.btn-primary { @apply inline-flex items-center justify-center gap-2 rounded-xl bg-blue-600 px-4 py-3 text-sm font-bold text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50; }
|
||||||
|
.btn-secondary { @apply inline-flex items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 transition hover:border-blue-200 hover:bg-blue-50; }
|
||||||
|
.icon-btn { @apply inline-flex h-10 w-10 items-center justify-center rounded-xl text-slate-500 transition hover:bg-slate-100 hover:text-blue-600; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar::-webkit-scrollbar { width: 9px; height: 9px; }
|
||||||
|
.scrollbar::-webkit-scrollbar-thumb { background: #cbd5e1; border: 2px solid transparent; border-radius: 999px; background-clip: padding-box; }
|
||||||
|
.splash-dot { animation: float 5s ease-in-out infinite; }
|
||||||
|
@keyframes float { 0%,100% { transform: translateY(0) scale(1); } 50% { transform: translateY(-18px) scale(1.06); } }
|
||||||
9
vite.config.js
Normal file
9
vite.config.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
base: './',
|
||||||
|
server: { port: 5173, strictPort: true },
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user