first commit
This commit is contained in:
commit
d038b3b778
1
.env.example
Normal file
1
.env.example
Normal file
@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=https://filedriveapi.thedomainnest.com/api
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
18
README.md
Normal file
18
README.md
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Metatron.Drive Admin
|
||||||
|
|
||||||
|
Administrator frontend for Metatron.Drive user management.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure the API using `VITE_API_BASE_URL`. Administrator credentials are configured only on the backend through `ADMIN_USERNAME` and `ADMIN_PASSWORD`.
|
||||||
27
eslint.config.js
Normal file
27
eslint.config.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import react from 'eslint-plugin-react'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{ ignores: ['dist', 'node_modules'] },
|
||||||
|
{
|
||||||
|
files: ['src/**/*.{js,jsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: { ecmaVersion: 'latest', ecmaFeatures: { jsx: true }, sourceType: 'module' },
|
||||||
|
},
|
||||||
|
plugins: { react, 'react-hooks': reactHooks, 'react-refresh': reactRefresh },
|
||||||
|
settings: { react: { version: 'detect' } },
|
||||||
|
rules: {
|
||||||
|
...js.configs.recommended.rules,
|
||||||
|
...react.configs.recommended.rules,
|
||||||
|
...reactHooks.configs.flat.recommended.rules,
|
||||||
|
...reactRefresh.configs.vite.rules,
|
||||||
|
'react/react-in-jsx-scope': 'off',
|
||||||
|
'react/prop-types': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
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 Admin</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
5612
package-lock.json
generated
Normal file
5612
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
package.json
Normal file
30
package.json
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "metatron-drive-admin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"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",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
188
src/App.jsx
Normal file
188
src/App.jsx
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Ban, CheckCircle2, ChevronLeft, ChevronRight, Cloud, Database,
|
||||||
|
Edit3, Eye, EyeOff, File, Folder, HardDrive, KeyRound, LoaderCircle, LockKeyhole,
|
||||||
|
LogOut, RefreshCw, Search, ShieldCheck, Trash2, UserCheck, Users, X, XCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { adminApi, messageFrom } from './api.js'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [status, setStatus] = useState(() => sessionStorage.getItem('metatron-admin-token') ? 'loading' : 'signedOut')
|
||||||
|
const [admin, setAdmin] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = sessionStorage.getItem('metatron-admin-token')
|
||||||
|
if (!token) return
|
||||||
|
adminApi.me().then((profile) => { setAdmin(profile); setStatus('signedIn') }).catch(() => {
|
||||||
|
sessionStorage.removeItem('metatron-admin-token')
|
||||||
|
setStatus('signedOut')
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function authenticated(result) {
|
||||||
|
sessionStorage.setItem('metatron-admin-token', result.token)
|
||||||
|
setAdmin(result.admin)
|
||||||
|
setStatus('signedIn')
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
sessionStorage.removeItem('metatron-admin-token')
|
||||||
|
setAdmin(null)
|
||||||
|
setStatus('signedOut')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'loading') return <LoadingScreen />
|
||||||
|
if (status === 'signedOut') return <LoginScreen onAuthenticated={authenticated} />
|
||||||
|
return <Dashboard admin={admin} onLogout={logout} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingScreen() {
|
||||||
|
return <main className="flex min-h-screen items-center justify-center bg-[#101d42]"><LoaderCircle className="animate-spin text-emerald-300" size={38} /></main>
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginScreen({ onAuthenticated }) {
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [hidden, setHidden] = useState(true)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function submit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
setBusy(true)
|
||||||
|
setError('')
|
||||||
|
try { onAuthenticated(await adminApi.login(username.trim(), password)) }
|
||||||
|
catch (requestError) { setError(messageFrom(requestError)) }
|
||||||
|
finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return <main className="grid min-h-screen lg:grid-cols-[1.1fr_0.9fr]">
|
||||||
|
<section className="relative hidden overflow-hidden bg-[#101d42] p-16 text-white lg:flex lg:flex-col lg:justify-between">
|
||||||
|
<div className="absolute -left-40 -top-40 h-[30rem] w-[30rem] rounded-full bg-blue-600/20" />
|
||||||
|
<div className="absolute -bottom-48 right-0 h-[34rem] w-[34rem] rounded-full bg-emerald-400/10" />
|
||||||
|
<div className="relative 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 fill="currentColor" size={25} /></span>Metatron.Drive</div>
|
||||||
|
<div className="relative max-w-xl"><p className="mb-4 text-sm font-bold uppercase tracking-[.25em] text-emerald-300">Administrator control</p><h1 className="text-5xl font-black leading-tight">Manage your digital family securely.</h1><p className="mt-6 text-lg leading-8 text-white/60">Review accounts, update user details, reset passwords, suspend access, and manage database records.</p></div>
|
||||||
|
<p className="relative text-sm text-white/35">Restricted administrator access</p>
|
||||||
|
</section>
|
||||||
|
<section className="flex items-center justify-center bg-[#f5f7fb] p-8"><form className="w-full max-w-md" onSubmit={submit}>
|
||||||
|
<span className="mb-7 flex h-14 w-14 items-center justify-center rounded-2xl bg-blue-600 text-white shadow-lg shadow-blue-200"><ShieldCheck size={29} /></span>
|
||||||
|
<h2 className="text-3xl font-black text-slate-900">Admin sign in</h2><p className="mt-2 text-slate-500">Enter the administrator credentials configured on the API server.</p>
|
||||||
|
<div className="mt-8 space-y-4"><label className="relative block"><Users className="absolute left-4 top-3 text-slate-400" size={19} /><input className="field pl-11" value={username} onChange={(event) => setUsername(event.target.value)} placeholder="Administrator username" autoFocus /></label><label className="relative block"><LockKeyhole className="absolute left-4 top-3 text-slate-400" size={19} /><input className="field px-11" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="Administrator password" type={hidden ? 'password' : 'text'} /><button className="icon-btn absolute right-2 top-1.5" type="button" onClick={() => setHidden(!hidden)}>{hidden ? <Eye size={18} /> : <EyeOff size={18} />}</button></label></div>
|
||||||
|
{error && <div className="mt-5 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">{error}</div>}
|
||||||
|
<button className="btn-primary mt-6 w-full py-3" disabled={busy || !username || !password}>{busy ? <LoaderCircle className="animate-spin" size={20} /> : <><KeyRound size={18} /> Sign in securely</>}</button>
|
||||||
|
</form></section>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
|
||||||
|
function Dashboard({ admin, onLogout }) {
|
||||||
|
const [stats, setStats] = useState(null)
|
||||||
|
const [users, setUsers] = useState([])
|
||||||
|
const [pagination, setPagination] = useState({ page: 1, pages: 1, total: 0 })
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [searchInput, setSearchInput] = useState('')
|
||||||
|
const [filter, setFilter] = useState('all')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [editing, setEditing] = useState(null)
|
||||||
|
const [toast, setToast] = useState(null)
|
||||||
|
|
||||||
|
const load = useCallback(async (page = 1) => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const [nextStats, result] = await Promise.all([
|
||||||
|
adminApi.stats(),
|
||||||
|
adminApi.users({ search, status: filter, page, limit: 25 }),
|
||||||
|
])
|
||||||
|
setStats(nextStats)
|
||||||
|
setUsers(result.users)
|
||||||
|
setPagination(result.pagination)
|
||||||
|
} catch (requestError) {
|
||||||
|
if (requestError?.response?.status === 401) onLogout()
|
||||||
|
else setError(messageFrom(requestError))
|
||||||
|
} finally { setLoading(false) }
|
||||||
|
}, [filter, onLogout, search])
|
||||||
|
|
||||||
|
useEffect(() => { const timer = setTimeout(() => load(1), 0); return () => clearTimeout(timer) }, [load])
|
||||||
|
useEffect(() => { if (!toast) return undefined; const timer = setTimeout(() => setToast(null), 3500); return () => clearTimeout(timer) }, [toast])
|
||||||
|
|
||||||
|
function submitSearch(event) { event.preventDefault(); setSearch(searchInput.trim()) }
|
||||||
|
|
||||||
|
async function saveUser(id, input) {
|
||||||
|
try {
|
||||||
|
await adminApi.updateUser(id, input)
|
||||||
|
setEditing(null)
|
||||||
|
setToast({ type: 'success', text: 'User updated successfully.' })
|
||||||
|
load(pagination.page)
|
||||||
|
} catch (requestError) { setToast({ type: 'error', text: messageFrom(requestError) }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleSuspension(user) {
|
||||||
|
try {
|
||||||
|
await adminApi.updateUser(user.id, { isSuspended: !user.isSuspended })
|
||||||
|
setToast({ type: 'success', text: user.isSuspended ? 'User access restored.' : 'User suspended.' })
|
||||||
|
load(pagination.page)
|
||||||
|
} catch (requestError) { setToast({ type: 'error', text: messageFrom(requestError) }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser(user) {
|
||||||
|
if (!window.confirm(`Delete ${user.name} from the database? Their Google Drive files will remain untouched.`)) return
|
||||||
|
try {
|
||||||
|
await adminApi.deleteUser(user.id)
|
||||||
|
setToast({ type: 'success', text: 'User database records deleted.' })
|
||||||
|
load(Math.min(pagination.page, pagination.pages))
|
||||||
|
} catch (requestError) { setToast({ type: 'error', text: messageFrom(requestError) }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return <main className="min-h-screen bg-[#f5f7fb]">
|
||||||
|
<aside className="fixed inset-y-0 left-0 z-20 flex w-64 flex-col bg-[#101d42] p-5 text-white">
|
||||||
|
<div className="flex items-center gap-3 px-2 text-lg font-black"><span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-blue-600"><Cloud fill="currentColor" size={22} /></span>Metatron.Drive</div>
|
||||||
|
<div className="mt-10 rounded-xl bg-white/10 px-4 py-3"><div className="flex items-center gap-3"><Database className="text-emerald-300" size={20} /><div><p className="text-sm font-bold">User database</p><p className="text-xs text-white/40">Management console</p></div></div></div>
|
||||||
|
<div className="mt-auto rounded-2xl border border-white/10 p-3"><p className="text-xs text-white/40">Signed in as</p><p className="mt-1 text-sm font-bold">{admin.username}</p><button className="mt-3 flex items-center gap-2 text-xs font-bold text-white/60 hover:text-white" onClick={onLogout}><LogOut size={15} /> Sign out</button></div>
|
||||||
|
</aside>
|
||||||
|
<section className="ml-64 min-h-screen">
|
||||||
|
<header className="sticky top-0 z-10 flex h-20 items-center justify-between border-b border-slate-200 bg-white/90 px-8 backdrop-blur"><div><h1 className="text-xl font-black text-slate-900">Administrator dashboard</h1><p className="mt-0.5 text-xs text-slate-500">Manage Metatron.Drive users and access</p></div><button className="btn-secondary" onClick={() => load(pagination.page)}><RefreshCw size={17} /> Refresh</button></header>
|
||||||
|
<div className="p-8">
|
||||||
|
<Stats stats={stats} />
|
||||||
|
<section className="card mt-7 overflow-hidden">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-slate-200 p-5"><div><h2 className="font-black text-slate-900">Users</h2><p className="mt-1 text-xs text-slate-500">{pagination.total} registered account{pagination.total === 1 ? '' : 's'}</p></div><div className="flex gap-3"><form className="relative" onSubmit={submitSearch}><Search className="absolute left-3.5 top-2.5 text-slate-400" size={18} /><input className="field w-72 py-2 pl-10" placeholder="Search name, username, email…" value={searchInput} onChange={(event) => setSearchInput(event.target.value)} /></form><select className="field w-36 py-2" value={filter} onChange={(event) => setFilter(event.target.value)}><option value="all">All users</option><option value="active">Active</option><option value="suspended">Suspended</option></select></div></div>
|
||||||
|
{error ? <ErrorState message={error} retry={() => load(pagination.page)} /> : loading ? <div className="flex h-72 items-center justify-center"><LoaderCircle className="animate-spin text-blue-600" size={32} /></div> : users.length === 0 ? <div className="flex h-72 flex-col items-center justify-center"><Users className="text-slate-300" size={48} /><h3 className="mt-4 font-bold text-slate-700">No users found</h3></div> : <UserTable users={users} onEdit={setEditing} onToggle={toggleSuspension} onDelete={deleteUser} />}
|
||||||
|
<div className="flex items-center justify-between border-t border-slate-200 px-5 py-4 text-sm"><p className="text-slate-500">Page {pagination.page} of {pagination.pages}</p><div className="flex gap-2"><button className="icon-btn border border-slate-200" disabled={pagination.page <= 1} onClick={() => load(pagination.page - 1)}><ChevronLeft size={18} /></button><button className="icon-btn border border-slate-200" disabled={pagination.page >= pagination.pages} onClick={() => load(pagination.page + 1)}><ChevronRight size={18} /></button></div></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{editing && <EditUserModal user={editing} onClose={() => setEditing(null)} onSave={saveUser} />}
|
||||||
|
{toast && <Toast toast={toast} />}
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stats({ stats }) {
|
||||||
|
const cards = [
|
||||||
|
{ label: 'Total users', value: stats?.users, icon: Users, tone: 'text-blue-600 bg-blue-50' },
|
||||||
|
{ label: 'Active users', value: stats?.activeUsers, icon: UserCheck, tone: 'text-emerald-600 bg-emerald-50' },
|
||||||
|
{ label: 'Suspended', value: stats?.suspendedUsers, icon: Ban, tone: 'text-amber-600 bg-amber-50' },
|
||||||
|
{ label: 'Stored files', value: stats?.files, icon: File, tone: 'text-violet-600 bg-violet-50' },
|
||||||
|
{ label: 'Storage indexed', value: stats ? fileSize(stats.storageBytes) : '—', icon: HardDrive, tone: 'text-cyan-600 bg-cyan-50' },
|
||||||
|
]
|
||||||
|
return <div className="grid grid-cols-5 gap-4">{cards.map(({ label, value, icon: Icon, tone }) => <div className="card p-4" key={label}><span className={`flex h-10 w-10 items-center justify-center rounded-xl ${tone}`}><Icon size={20} /></span><p className="mt-4 text-2xl font-black text-slate-900">{value ?? '—'}</p><p className="mt-1 text-xs font-semibold text-slate-500">{label}</p></div>)}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserTable({ users, onEdit, onToggle, onDelete }) {
|
||||||
|
return <div className="scrollbar overflow-x-auto"><table className="w-full min-w-[1050px] text-left"><thead className="bg-slate-50 text-[11px] uppercase tracking-wider text-slate-400"><tr><th className="px-5 py-3">User</th><th className="px-5 py-3">Contact</th><th className="px-5 py-3">Content</th><th className="px-5 py-3">Joined</th><th className="px-5 py-3">Status</th><th className="px-5 py-3 text-right">Actions</th></tr></thead><tbody className="divide-y divide-slate-100">{users.map((user) => <tr className="hover:bg-slate-50/70" key={user.id}><td className="px-5 py-4"><div className="flex items-center gap-3"><span className="flex h-10 w-10 items-center justify-center rounded-xl bg-[#101d42] text-xs font-black text-white">{initials(user.name)}</span><div><p className="text-sm font-bold text-slate-800">{user.name}</p><p className="mt-0.5 text-xs text-slate-400">@{user.username}</p></div></div></td><td className="px-5 py-4"><p className="text-sm text-slate-700">{user.email}</p><p className="mt-1 text-xs text-slate-400">{displayPhone(user.phone)}</p></td><td className="px-5 py-4"><div className="flex gap-3 text-xs text-slate-500"><span className="flex items-center gap-1"><Folder size={14} />{user.counts?.folders || 0}</span><span className="flex items-center gap-1"><File size={14} />{user.counts?.files || 0}</span></div></td><td className="px-5 py-4 text-xs text-slate-500">{dateTime(user.createdAt)}</td><td className="px-5 py-4"><span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold ${user.isSuspended ? 'bg-amber-50 text-amber-700' : 'bg-emerald-50 text-emerald-700'}`}>{user.isSuspended ? <Ban size={13} /> : <CheckCircle2 size={13} />}{user.isSuspended ? 'Suspended' : 'Active'}</span></td><td className="px-5 py-4"><div className="flex justify-end gap-1"><button className="icon-btn" title="Edit user" onClick={() => onEdit(user)}><Edit3 size={17} /></button><button className={`icon-btn ${user.isSuspended ? 'text-emerald-600' : 'text-amber-600'}`} title={user.isSuspended ? 'Restore access' : 'Suspend access'} onClick={() => onToggle(user)}>{user.isSuspended ? <UserCheck size={17} /> : <Ban size={17} />}</button><button className="icon-btn text-red-500 hover:text-red-600" title="Delete user" onClick={() => onDelete(user)}><Trash2 size={17} /></button></div></td></tr>)}</tbody></table></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditUserModal({ user, onClose, onSave }) {
|
||||||
|
const [form, setForm] = useState({ name: user.name, username: user.username, email: user.email, phone: user.phone, gender: user.gender, age: String(user.age), createdAt: toLocalInput(user.createdAt), isSuspended: user.isSuspended, password: '' })
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
async function submit(event) { event.preventDefault(); setBusy(true); const input = { ...form, age: Number(form.age), createdAt: new Date(form.createdAt).toISOString() }; if (!input.password) delete input.password; await onSave(user.id, input); setBusy(false) }
|
||||||
|
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 max-h-[92vh] w-full max-w-2xl overflow-hidden" onMouseDown={(event) => event.stopPropagation()}><header className="flex items-center justify-between border-b border-slate-200 px-6 py-4"><div><h2 className="text-lg font-black">Edit user</h2><p className="mt-0.5 text-xs text-slate-500">Update account details and access</p></div><button className="icon-btn" onClick={onClose}><X size={20} /></button></header><form className="scrollbar max-h-[calc(92vh-76px)] overflow-y-auto p-6" onSubmit={submit}><div className="grid grid-cols-2 gap-4"><Field label="Full name" value={form.name} set={(value) => setForm({ ...form, name: value })} /><Field label="Username" value={form.username} set={(value) => setForm({ ...form, username: value })} /><Field label="Email address" type="email" value={form.email} set={(value) => setForm({ ...form, email: value })} /><Field label="Phone number" value={form.phone} set={(value) => setForm({ ...form, phone: value })} /><label><span className="mb-1.5 block text-xs font-bold text-slate-500">Gender</span><select className="field" value={form.gender} onChange={(event) => setForm({ ...form, gender: event.target.value })}><option>Male</option><option>Female</option><option>Non-binary</option><option>Prefer not to say</option></select></label><Field label="Age" type="number" min="13" max="120" value={form.age} set={(value) => setForm({ ...form, age: value })} /><Field label="Created date and time" type="datetime-local" value={form.createdAt} set={(value) => setForm({ ...form, createdAt: value })} /><Field label="New password (leave blank to keep)" type="password" value={form.password} set={(value) => setForm({ ...form, password: value })} /><label className="col-span-2 flex items-center justify-between rounded-xl border border-slate-200 p-4"><span><span className="block text-sm font-bold text-slate-700">Suspend account</span><span className="mt-1 block text-xs text-slate-400">The user cannot log in or use an existing session.</span></span><input className="h-5 w-5 accent-blue-600" type="checkbox" checked={form.isSuspended} onChange={(event) => setForm({ ...form, isSuspended: event.target.checked })} /></label></div><div className="mt-6 flex justify-end gap-3"><button type="button" className="btn-secondary" onClick={onClose}>Cancel</button><button className="btn-primary" disabled={busy}>{busy ? <LoaderCircle className="animate-spin" size={18} /> : <><Edit3 size={17} /> Save changes</>}</button></div></form></section></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, type = 'text', value, set, ...props }) { return <label><span className="mb-1.5 block text-xs font-bold text-slate-500">{label}</span><input className="field" type={type} value={value} onChange={(event) => set(event.target.value)} required={!label.includes('leave blank')} {...props} /></label> }
|
||||||
|
function ErrorState({ message, retry }) { return <div className="flex h-72 flex-col items-center justify-center"><XCircle className="text-red-500" size={40} /><p className="mt-4 text-sm font-bold text-slate-700">{message}</p><button className="btn-secondary mt-4" onClick={retry}><RefreshCw size={17} /> Retry</button></div> }
|
||||||
|
function Toast({ toast }) { return <div className={`fixed right-6 top-6 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={18} /> : <CheckCircle2 size={18} />}{toast.text}</div> }
|
||||||
|
function initials(name = '') { return name.split(/\s+/).filter(Boolean).slice(0, 2).map((part) => part[0].toUpperCase()).join('') || 'U' }
|
||||||
|
function displayPhone(phone = '') { return phone.startsWith('wd') ? 'Not provided' : phone }
|
||||||
|
function dateTime(value) { return new Intl.DateTimeFormat('en-IN', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value)) }
|
||||||
|
function toLocalInput(value) { const date = new Date(value); const offset = date.getTimezoneOffset() * 60000; return new Date(date.getTime() - offset).toISOString().slice(0, 16) }
|
||||||
|
function fileSize(bytes = 0) { const value = Number(bytes); 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` }
|
||||||
25
src/api.js
Normal file
25
src/api.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const client = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_BASE_URL || 'https://filedriveapi.thedomainnest.com/api',
|
||||||
|
timeout: 30000,
|
||||||
|
})
|
||||||
|
|
||||||
|
client.interceptors.request.use((config) => {
|
||||||
|
const token = sessionStorage.getItem('metatron-admin-token')
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
export function messageFrom(error) {
|
||||||
|
return error?.response?.data?.error || error?.message || 'Something went wrong.'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminApi = {
|
||||||
|
async login(username, password) { return (await client.post('/admin/login', { username, password })).data },
|
||||||
|
async me() { return (await client.get('/admin/me')).data.admin },
|
||||||
|
async stats() { return (await client.get('/admin/stats')).data.stats },
|
||||||
|
async users(params) { return (await client.get('/admin/users', { params })).data },
|
||||||
|
async updateUser(id, input) { return (await client.patch(`/admin/users/${id}`, input)).data.user },
|
||||||
|
deleteUser(id) { return client.delete(`/admin/users/${id}`) },
|
||||||
|
}
|
||||||
6
src/main.jsx
Normal file
6
src/main.jsx
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
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>)
|
||||||
18
src/styles.css
Normal file
18
src/styles.css
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root { font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #101d42; background: #f5f7fb; font-synthesis: none; text-rendering: optimizeLegibility; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body, #root { min-height: 100%; margin: 0; }
|
||||||
|
button, input, select { 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-2.5 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-2.5 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-bold text-slate-600 transition hover:border-blue-200 hover:bg-blue-50; }
|
||||||
|
.icon-btn { @apply inline-flex h-9 w-9 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: 99px; background-clip: padding-box; }
|
||||||
8
vite.config.js
Normal file
8
vite.config.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: { port: 5174, strictPort: true },
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user