feat: improve upload admin workflow
Add users.is_admin SQLite migration for promoted admins. Support curl PUT request uploads, save uploads into admin file-manager paths, default 25 GiB uploads, tabbed UI, and local dev preview.
This commit is contained in:
@@ -63,7 +63,7 @@ export function UploadProgressForm({ csrfToken }) {
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
const redirectPath = xhr.response?.redirect || '/manage/dashboard?success=Upload%20abgeschlossen.';
|
||||
const redirectPath = xhr.response?.redirect || '/manage/dashboard?tab=upload&success=Upload%20abgeschlossen.';
|
||||
window.location.assign(redirectPath);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import {
|
||||
adminDeleteUploadAction,
|
||||
adminExtendUploadAction,
|
||||
adminLogoutAction,
|
||||
adminSaveUploadToPathAction,
|
||||
} from '@/src/lib/actions.js';
|
||||
import { adminHash } from '@/src/lib/config.js';
|
||||
import { all, get, runCleanupIfNeeded } from '@/src/lib/db.js';
|
||||
import { sharedLinkName } from '@/src/lib/files.js';
|
||||
import {
|
||||
@@ -23,20 +23,6 @@ export const dynamic = 'force-dynamic';
|
||||
export default async function AdminDashboardPage({ searchParams }) {
|
||||
await runCleanupIfNeeded();
|
||||
|
||||
if (!adminHash) {
|
||||
return (
|
||||
<main className="page-shell narrow">
|
||||
<section className="panel centered">
|
||||
<h1>Adminzugang nicht konfiguriert</h1>
|
||||
<p className="muted">Setze MANAGEMENT_ADMIN_HASH in der Umgebungskonfiguration.</p>
|
||||
<a className="btn secondary" href="/manage/login">
|
||||
Zurück
|
||||
</a>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
await requireAdminUser();
|
||||
const csrfToken = await ensureCsrfToken();
|
||||
|
||||
@@ -50,6 +36,7 @@ export default async function AdminDashboardPage({ searchParams }) {
|
||||
lastCleanup,
|
||||
recentLogs,
|
||||
allUploads,
|
||||
uploadRequests,
|
||||
] = await Promise.all([
|
||||
get('SELECT COUNT(*) AS count FROM uploads'),
|
||||
get('SELECT COALESCE(SUM(size_bytes), 0) AS total FROM uploads'),
|
||||
@@ -68,11 +55,19 @@ export default async function AdminDashboardPage({ searchParams }) {
|
||||
all(
|
||||
'SELECT id, owner, original_name, stored_name, size_bytes, uploaded_at, expires_at FROM uploads ORDER BY uploaded_at DESC LIMIT 500'
|
||||
),
|
||||
all(
|
||||
`SELECT id, owner, note, created_at, expires_at, completed_at, upload_id, uploaded_original_name, uploaded_size_bytes
|
||||
FROM upload_requests
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 500`
|
||||
),
|
||||
]);
|
||||
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const error = readSearchParam(resolvedSearchParams, 'error');
|
||||
const success = readSearchParam(resolvedSearchParams, 'success');
|
||||
const requestedTab = readSearchParam(resolvedSearchParams, 'tab');
|
||||
const activeTab = ['stats', 'uploads', 'requests', 'logs'].includes(requestedTab) ? requestedTab : 'stats';
|
||||
|
||||
return (
|
||||
<main className="page-shell">
|
||||
@@ -100,6 +95,22 @@ export default async function AdminDashboardPage({ searchParams }) {
|
||||
|
||||
<StatusMessage error={error} success={success} />
|
||||
|
||||
<nav className="tabs" aria-label="Admin-Bereiche">
|
||||
<a className={`tab ${activeTab === 'stats' ? 'active' : ''}`} href="/manage/admin/dashboard?tab=stats">
|
||||
Statistik
|
||||
</a>
|
||||
<a className={`tab ${activeTab === 'uploads' ? 'active' : ''}`} href="/manage/admin/dashboard?tab=uploads">
|
||||
Uploads
|
||||
</a>
|
||||
<a className={`tab ${activeTab === 'requests' ? 'active' : ''}`} href="/manage/admin/dashboard?tab=requests">
|
||||
Anfragen
|
||||
</a>
|
||||
<a className={`tab ${activeTab === 'logs' ? 'active' : ''}`} href="/manage/admin/dashboard?tab=logs">
|
||||
Logs
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{activeTab === 'stats' ? (
|
||||
<section className="panel">
|
||||
<h2>Statistiken</h2>
|
||||
<div className="metric-grid">
|
||||
@@ -133,7 +144,9 @@ export default async function AdminDashboardPage({ searchParams }) {
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'uploads' ? (
|
||||
<section className="panel">
|
||||
<h2>Aktuelle Uploads</h2>
|
||||
{allUploads.length === 0 ? (
|
||||
@@ -169,7 +182,9 @@ export default async function AdminDashboardPage({ searchParams }) {
|
||||
<div className="muted">Noch {formatCountdown(item.expires_at)}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="stack-actions">
|
||||
<details className="action-menu">
|
||||
<summary>Aktionen</summary>
|
||||
<div className="action-menu-panel">
|
||||
<div className="row-actions">
|
||||
<a className="btn secondary" href={sharePath}>
|
||||
Download
|
||||
@@ -177,6 +192,21 @@ export default async function AdminDashboardPage({ searchParams }) {
|
||||
<CopyLinkButton path={sharePath} label={item.original_name} />
|
||||
</div>
|
||||
|
||||
<form className="inline-form stacked" action={adminSaveUploadToPathAction}>
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<input type="hidden" name="uploadId" value={item.id} />
|
||||
<input type="hidden" name="redirectTab" value="uploads" />
|
||||
<input
|
||||
className="input small path-input"
|
||||
name="targetPath"
|
||||
placeholder="Ziel, z.B. inbox/"
|
||||
required
|
||||
/>
|
||||
<button className="btn secondary" type="submit">
|
||||
Im Dateimanager speichern
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form className="inline-form action-form-row">
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<input type="hidden" name="uploadId" value={item.id} />
|
||||
@@ -188,7 +218,8 @@ export default async function AdminDashboardPage({ searchParams }) {
|
||||
Löschen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -198,7 +229,95 @@ export default async function AdminDashboardPage({ searchParams }) {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'requests' ? (
|
||||
<section className="panel">
|
||||
<h2>Upload-Anfragen</h2>
|
||||
{uploadRequests.length === 0 ? (
|
||||
<p className="muted">Keine Upload-Anfragen vorhanden.</p>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Anfrage</th>
|
||||
<th>Nutzer</th>
|
||||
<th>Status</th>
|
||||
<th>Erstellt</th>
|
||||
<th>Ablauf</th>
|
||||
<th>Datei</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{uploadRequests.map((entry) => {
|
||||
const isCompleted = Number(entry.completed_at || 0) > 0;
|
||||
const isExpired = !isCompleted && Number(entry.expires_at || 0) <= Date.now();
|
||||
const requestPath = `/_request/${encodeURIComponent(entry.id)}`;
|
||||
|
||||
return (
|
||||
<tr key={entry.id}>
|
||||
<td>
|
||||
<strong>{entry.id}</strong>
|
||||
{entry.note ? <div className="muted">{entry.note}</div> : null}
|
||||
</td>
|
||||
<td>{entry.owner}</td>
|
||||
<td>
|
||||
<span className={`badge ${isCompleted ? 'success' : isExpired ? 'danger' : 'primary'}`}>
|
||||
{isCompleted ? 'Abgeschlossen' : isExpired ? 'Abgelaufen' : 'Offen'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{formatTimestamp(entry.created_at)}</td>
|
||||
<td>{formatTimestamp(entry.expires_at)}</td>
|
||||
<td>
|
||||
{entry.uploaded_original_name ? (
|
||||
<>
|
||||
<strong>{entry.uploaded_original_name}</strong>
|
||||
<div className="muted">{formatBytes(entry.uploaded_size_bytes || 0)}</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="muted">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<details className="action-menu">
|
||||
<summary>Aktionen</summary>
|
||||
<div className="action-menu-panel">
|
||||
<a className="btn secondary" href={requestPath}>
|
||||
Öffnen
|
||||
</a>
|
||||
|
||||
{entry.upload_id ? (
|
||||
<form className="inline-form stacked" action={adminSaveUploadToPathAction}>
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<input type="hidden" name="uploadId" value={entry.upload_id} />
|
||||
<input type="hidden" name="redirectTab" value="requests" />
|
||||
<input
|
||||
className="input small path-input"
|
||||
name="targetPath"
|
||||
placeholder="Ziel, z.B. inbox/"
|
||||
required
|
||||
/>
|
||||
<button className="btn secondary" type="submit">
|
||||
Im Dateimanager speichern
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'logs' ? (
|
||||
<section className="panel">
|
||||
<h2>Letzte Ereignisse</h2>
|
||||
{recentLogs.length === 0 ? (
|
||||
@@ -247,6 +366,7 @@ export default async function AdminDashboardPage({ searchParams }) {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,12 @@ function buildBreadcrumbs(relativePath) {
|
||||
return breadcrumbs;
|
||||
}
|
||||
|
||||
function adminFilesTabHref(relativePath, tab) {
|
||||
const baseHref = adminFilesHref(relativePath);
|
||||
const separator = baseHref.includes('?') ? '&' : '?';
|
||||
return `${baseHref}${separator}tab=${encodeURIComponent(tab)}`;
|
||||
}
|
||||
|
||||
export default async function AdminFilesPage({ searchParams }) {
|
||||
await runCleanupIfNeeded();
|
||||
await requireAdminUser();
|
||||
@@ -45,6 +51,8 @@ export default async function AdminFilesPage({ searchParams }) {
|
||||
|
||||
const queryError = readSearchParam(resolvedSearchParams, 'error');
|
||||
const success = readSearchParam(resolvedSearchParams, 'success');
|
||||
const requestedTab = readSearchParam(resolvedSearchParams, 'tab');
|
||||
const activeTab = ['content', 'folder', 'upload'].includes(requestedTab) ? requestedTab : 'content';
|
||||
|
||||
const absolutePath = resolveAdminPath(relativePath);
|
||||
if (!absolutePath) {
|
||||
@@ -164,8 +172,20 @@ export default async function AdminFilesPage({ searchParams }) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel" style={{ display: 'grid', gap: '0.8rem', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))' }}>
|
||||
<div>
|
||||
<nav className="tabs" aria-label="Dateimanager-Bereiche">
|
||||
<a className={`tab ${activeTab === 'content' ? 'active' : ''}`} href={adminFilesTabHref(relativePath, 'content')}>
|
||||
Inhalt
|
||||
</a>
|
||||
<a className={`tab ${activeTab === 'folder' ? 'active' : ''}`} href={adminFilesTabHref(relativePath, 'folder')}>
|
||||
Neuer Ordner
|
||||
</a>
|
||||
<a className={`tab ${activeTab === 'upload' ? 'active' : ''}`} href={adminFilesTabHref(relativePath, 'upload')}>
|
||||
Upload
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{activeTab === 'folder' ? (
|
||||
<section className="panel">
|
||||
<h2>Ordner erstellen</h2>
|
||||
<form className="form-grid" action={adminMkdirAction}>
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
@@ -180,9 +200,11 @@ export default async function AdminFilesPage({ searchParams }) {
|
||||
Erstellen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
{activeTab === 'upload' ? (
|
||||
<section className="panel">
|
||||
<h2>Datei hochladen</h2>
|
||||
<form className="form-grid" action={adminUploadToPathAction} encType="multipart/form-data">
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
@@ -197,9 +219,10 @@ export default async function AdminFilesPage({ searchParams }) {
|
||||
Hochladen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'content' ? (
|
||||
<section className="panel">
|
||||
<h2>Inhalt</h2>
|
||||
{details.length === 0 ? (
|
||||
@@ -233,44 +256,47 @@ export default async function AdminFilesPage({ searchParams }) {
|
||||
<td>{item.size != null ? formatBytes(item.size) : '-'}</td>
|
||||
<td>{item.modifiedAt ? formatTimestamp(item.modifiedAt) : '-'}</td>
|
||||
<td>
|
||||
<div className="stack-actions">
|
||||
<form className="inline-form stacked" action={adminRenamePathAction}>
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<input type="hidden" name="path" value={item.childPath} />
|
||||
<input className="input small" name="newName" placeholder="Neuer Name" required />
|
||||
<button className="btn secondary" type="submit">
|
||||
Umbenennen
|
||||
</button>
|
||||
</form>
|
||||
<details className="action-menu">
|
||||
<summary>Aktionen</summary>
|
||||
<div className="action-menu-panel">
|
||||
<form className="inline-form stacked" action={adminRenamePathAction}>
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<input type="hidden" name="path" value={item.childPath} />
|
||||
<input className="input small" name="newName" placeholder="Neuer Name" required />
|
||||
<button className="btn secondary" type="submit">
|
||||
Umbenennen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form className="inline-form stacked" action={adminMovePathAction}>
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<input type="hidden" name="path" value={item.childPath} />
|
||||
<input type="hidden" name="currentPath" value={relativePath} />
|
||||
<input className="input small" name="targetPath" placeholder="Zielpfad" required />
|
||||
<button className="btn secondary" type="submit">
|
||||
Verschieben
|
||||
</button>
|
||||
</form>
|
||||
<form className="inline-form stacked" action={adminMovePathAction}>
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<input type="hidden" name="path" value={item.childPath} />
|
||||
<input type="hidden" name="currentPath" value={relativePath} />
|
||||
<input className="input small" name="targetPath" placeholder="Zielpfad" required />
|
||||
<button className="btn secondary" type="submit">
|
||||
Verschieben
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form className="inline-form stacked" action={adminCopyPathAction}>
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<input type="hidden" name="path" value={item.childPath} />
|
||||
<input type="hidden" name="currentPath" value={relativePath} />
|
||||
<input className="input small" name="targetPath" placeholder="Zielpfad" required />
|
||||
<button className="btn secondary" type="submit">
|
||||
Kopieren
|
||||
</button>
|
||||
</form>
|
||||
<form className="inline-form stacked" action={adminCopyPathAction}>
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<input type="hidden" name="path" value={item.childPath} />
|
||||
<input type="hidden" name="currentPath" value={relativePath} />
|
||||
<input className="input small" name="targetPath" placeholder="Zielpfad" required />
|
||||
<button className="btn secondary" type="submit">
|
||||
Kopieren
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form className="inline-form" action={adminDeletePathAction}>
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<input type="hidden" name="path" value={item.childPath} />
|
||||
<button className="btn danger" type="submit">
|
||||
Löschen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<form className="inline-form" action={adminDeletePathAction}>
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<input type="hidden" name="path" value={item.childPath} />
|
||||
<button className="btn danger" type="submit">
|
||||
Löschen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -279,6 +305,7 @@ export default async function AdminFilesPage({ searchParams }) {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
adminDeleteUserAction,
|
||||
adminLogoutAction,
|
||||
adminResetUserAction,
|
||||
adminSetUserAdminAction,
|
||||
} from '@/src/lib/actions.js';
|
||||
import { all, runCleanupIfNeeded } from '@/src/lib/db.js';
|
||||
import { formatTimestamp, readSearchParam } from '@/src/lib/format.js';
|
||||
@@ -14,14 +15,16 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function AdminUsersPage({ searchParams }) {
|
||||
await runCleanupIfNeeded();
|
||||
await requireAdminUser();
|
||||
const actor = await requireAdminUser();
|
||||
|
||||
const csrfToken = await ensureCsrfToken();
|
||||
const users = await all('SELECT username, created_at FROM users ORDER BY username ASC');
|
||||
const users = await all('SELECT username, created_at, is_admin FROM users ORDER BY username ASC');
|
||||
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const error = readSearchParam(resolvedSearchParams, 'error');
|
||||
const success = readSearchParam(resolvedSearchParams, 'success');
|
||||
const requestedTab = readSearchParam(resolvedSearchParams, 'tab');
|
||||
const activeTab = requestedTab === 'create' ? 'create' : 'users';
|
||||
|
||||
return (
|
||||
<main className="page-shell">
|
||||
@@ -46,6 +49,16 @@ export default async function AdminUsersPage({ searchParams }) {
|
||||
|
||||
<StatusMessage error={error} success={success} />
|
||||
|
||||
<nav className="tabs" aria-label="Benutzer-Bereiche">
|
||||
<a className={`tab ${activeTab === 'users' ? 'active' : ''}`} href="/manage/admin/users?tab=users">
|
||||
Benutzer
|
||||
</a>
|
||||
<a className={`tab ${activeTab === 'create' ? 'active' : ''}`} href="/manage/admin/users?tab=create">
|
||||
Neu
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{activeTab === 'create' ? (
|
||||
<section className="panel">
|
||||
<h2>Neuen Benutzer anlegen</h2>
|
||||
<form className="form-grid" action={adminCreateUserAction}>
|
||||
@@ -61,12 +74,19 @@ export default async function AdminUsersPage({ searchParams }) {
|
||||
<input className="input" name="password" type="password" autoComplete="new-password" required />
|
||||
</label>
|
||||
|
||||
<label className="check-row">
|
||||
<input type="checkbox" name="isAdmin" value="1" />
|
||||
Adminrechte vergeben
|
||||
</label>
|
||||
|
||||
<button className="btn" type="submit">
|
||||
Benutzer erstellen
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'users' ? (
|
||||
<section className="panel">
|
||||
<h2>Bestehende Benutzer</h2>
|
||||
{users.length === 0 ? (
|
||||
@@ -77,6 +97,7 @@ export default async function AdminUsersPage({ searchParams }) {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Benutzername</th>
|
||||
<th>Rolle</th>
|
||||
<th>Erstellt</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
@@ -85,9 +106,27 @@ export default async function AdminUsersPage({ searchParams }) {
|
||||
{users.map((user) => (
|
||||
<tr key={user.username}>
|
||||
<td>{user.username}</td>
|
||||
<td>
|
||||
<span className={`badge ${user.is_admin ? 'primary' : ''}`}>
|
||||
{user.is_admin ? 'Admin' : 'Benutzer'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{formatTimestamp(user.created_at)}</td>
|
||||
<td>
|
||||
<div className="stack-actions">
|
||||
<form className="inline-form" action={adminSetUserAdminAction}>
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<input type="hidden" name="username" value={user.username} />
|
||||
<input type="hidden" name="isAdmin" value={user.is_admin ? '0' : '1'} />
|
||||
<button
|
||||
className="btn secondary"
|
||||
type="submit"
|
||||
disabled={actor.username === user.username && Boolean(user.is_admin)}
|
||||
>
|
||||
{user.is_admin ? 'Adminrechte entziehen' : 'Zum Admin machen'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form className="inline-form" action={adminResetUserAction}>
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<input type="hidden" name="username" value={user.username} />
|
||||
@@ -106,7 +145,7 @@ export default async function AdminUsersPage({ searchParams }) {
|
||||
<form className="inline-form" action={adminDeleteUserAction}>
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<input type="hidden" name="username" value={user.username} />
|
||||
<button className="btn danger" type="submit">
|
||||
<button className="btn danger" type="submit" disabled={actor.username === user.username}>
|
||||
Benutzer löschen
|
||||
</button>
|
||||
</form>
|
||||
@@ -119,6 +158,7 @@ export default async function AdminUsersPage({ searchParams }) {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ function errorResponse(request, message, status = 400) {
|
||||
return new NextResponse(null, {
|
||||
status: 303,
|
||||
headers: {
|
||||
location: dashboardHref({ error: message }),
|
||||
location: dashboardHref({ tab: 'upload', error: message }),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -84,7 +84,7 @@ function errorResponse(request, message, status = 400) {
|
||||
}
|
||||
|
||||
function successResponse(request, message) {
|
||||
const redirectPath = dashboardHref({ success: message });
|
||||
const redirectPath = dashboardHref({ tab: 'upload', success: message });
|
||||
if (expectsHtml(request)) {
|
||||
return new NextResponse(null, {
|
||||
status: 303,
|
||||
|
||||
@@ -38,6 +38,8 @@ export default async function DashboardPage({ searchParams }) {
|
||||
const success = readSearchParam(resolvedSearchParams, 'success');
|
||||
const totalBytes = uploads.reduce((total, item) => total + (Number(item.size_bytes) || 0), 0);
|
||||
const nowTs = Date.now();
|
||||
const requestedTab = readSearchParam(resolvedSearchParams, 'tab');
|
||||
const activeTab = ['upload', 'files', 'requests'].includes(requestedTab) ? requestedTab : 'upload';
|
||||
|
||||
return (
|
||||
<main className="page-shell">
|
||||
@@ -65,27 +67,31 @@ export default async function DashboardPage({ searchParams }) {
|
||||
|
||||
<StatusMessage error={error} success={success} />
|
||||
|
||||
<div className="dashboard-top-grid">
|
||||
<section className="panel panel-spotlight">
|
||||
<nav className="tabs" aria-label="Dashboard-Bereiche">
|
||||
<a className={`tab ${activeTab === 'upload' ? 'active' : ''}`} href="/manage/dashboard?tab=upload">
|
||||
Upload
|
||||
</a>
|
||||
<a className={`tab ${activeTab === 'files' ? 'active' : ''}`} href="/manage/dashboard?tab=files">
|
||||
Dateien
|
||||
</a>
|
||||
<a className={`tab ${activeTab === 'requests' ? 'active' : ''}`} href="/manage/dashboard?tab=requests">
|
||||
Anfragen
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div className="summary-line">
|
||||
<span>{uploads.length} aktive Uploads</span>
|
||||
<span>{totalBytes > 0 ? formatBytes(totalBytes) : '0 B'} genutzt</span>
|
||||
</div>
|
||||
|
||||
{activeTab === 'upload' ? (
|
||||
<section className="panel">
|
||||
<h2>Neue Datei hochladen</h2>
|
||||
<UploadProgressForm csrfToken={csrfToken} />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<aside className="panel panel-soft">
|
||||
<h2>Schnellüberblick</h2>
|
||||
<div className="info-stack">
|
||||
<div className="info-card">
|
||||
<strong>{uploads.length}</strong>
|
||||
<span className="muted">aktive Uploads</span>
|
||||
</div>
|
||||
<div className="info-card">
|
||||
<strong>{totalBytes > 0 ? formatBytes(totalBytes) : '0 B'}</strong>
|
||||
<span className="muted">genutzter Speicher</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{activeTab === 'requests' ? (
|
||||
<section className="panel">
|
||||
<h2>Upload-Anfragen</h2>
|
||||
<p className="muted">Benachrichtigung erfolgt an deinen Benutzernamen (E-Mail-Adresse).</p>
|
||||
@@ -162,7 +168,9 @@ export default async function DashboardPage({ searchParams }) {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'files' ? (
|
||||
<section className="panel">
|
||||
<h2>Aktuelle Uploads</h2>
|
||||
{uploads.length === 0 ? (
|
||||
@@ -225,6 +233,7 @@ export default async function DashboardPage({ searchParams }) {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user