diff --git a/nextjs/app/%5Fshare/[filename]/route.js b/nextjs/app/%5Fshare/[filename]/route.js index c4dbdf5..5800793 100644 --- a/nextjs/app/%5Fshare/[filename]/route.js +++ b/nextjs/app/%5Fshare/[filename]/route.js @@ -31,6 +31,28 @@ function contentDisposition(filename) { return `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`; } +function escapeLike(value) { + return String(value || '').replace(/[\\%_]/g, '\\$&'); +} + +async function findUploadRow(fileName) { + const exactRow = await get('SELECT id, original_name, stored_path FROM uploads WHERE stored_name = ?', [ + fileName, + ]); + if (exactRow || fileName.includes('.')) { + return exactRow; + } + + const likePattern = `${escapeLike(fileName)}.%`; + return get( + `SELECT id, original_name, stored_path FROM uploads + WHERE stored_name = ? OR stored_name LIKE ? ESCAPE '\\' + ORDER BY CASE WHEN stored_name = ? THEN 0 ELSE 1 END, id DESC + LIMIT 1`, + [fileName, likePattern, fileName] + ); +} + export async function GET(request, { params }) { await runCleanupIfNeeded(); @@ -40,7 +62,7 @@ export async function GET(request, { params }) { return new NextResponse('Ungültiger Dateiname', { status: 400 }); } - const row = await get('SELECT id, original_name, stored_path FROM uploads WHERE stored_name = ?', [fileName]); + const row = await findUploadRow(fileName); let filePath; let downloadName; diff --git a/nextjs/app/globals.css b/nextjs/app/globals.css index d6887d6..6cb7a17 100644 --- a/nextjs/app/globals.css +++ b/nextjs/app/globals.css @@ -5,6 +5,7 @@ --bg-accent: #d9ece4; --surface: #ffffff; --surface-soft: #f7fafc; + --surface-glass: rgb(255 255 255 / 0.72); --text-main: #10243a; --text-muted: #566b81; --line: #d6e0ea; @@ -59,7 +60,7 @@ p { margin: 0 auto; padding: 2.2rem 0 3.5rem; display: grid; - gap: 1.5rem; + gap: 1.6rem; animation: page-enter 240ms ease-out both; } @@ -73,6 +74,11 @@ p { align-items: center; justify-content: space-between; gap: 0.9rem; + background: linear-gradient(170deg, var(--surface-glass), rgb(255 255 255 / 0.5)); + border: 1px solid rgb(214 224 234 / 0.9); + border-radius: var(--radius); + padding: 0.95rem 1.05rem; + backdrop-filter: blur(6px); } .header-main { @@ -92,11 +98,34 @@ p { border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); - padding: 1.1rem; + padding: 1.2rem; display: grid; gap: 0.95rem; } +.panel.panel-soft { + background: linear-gradient(180deg, #f5fafc, #ffffff); +} + +.panel.panel-spotlight { + position: relative; + overflow: hidden; +} + +.panel.panel-spotlight::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background: + radial-gradient(circle at 90% 0%, rgb(15 118 110 / 0.12), transparent 45%), + radial-gradient(circle at 10% 100%, rgb(15 118 110 / 0.08), transparent 40%); +} + +.panel.panel-spotlight > * { + position: relative; +} + .panel.centered { text-align: center; justify-items: center; @@ -184,6 +213,12 @@ p { transform: translateY(1px); } +.btn:disabled { + opacity: 0.7; + cursor: not-allowed; + transform: none; +} + .btn.secondary { background: #fff; border-color: var(--line); @@ -245,6 +280,64 @@ p { letter-spacing: -0.02em; } +.dashboard-top-grid { + display: grid; + gap: 1rem; + grid-template-columns: minmax(0, 1.35fr) minmax(260px, 0.9fr); +} + +.info-stack { + display: grid; + gap: 0.65rem; +} + +.info-card { + border-radius: var(--radius-sm); + border: 1px solid var(--line); + background: #fff; + padding: 0.7rem 0.8rem; + display: grid; + gap: 0.2rem; +} + +.info-card strong { + font-family: var(--font-heading); + letter-spacing: -0.02em; + font-size: 1.02rem; +} + +.upload-progress { + display: grid; + gap: 0.45rem; + border-radius: var(--radius-sm); + border: 1px solid #bde1d9; + background: #ecf8f4; + padding: 0.58rem 0.66rem; +} + +.upload-progress-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.6rem; + font-size: 0.86rem; +} + +.upload-progress-track { + width: 100%; + height: 8px; + border-radius: 999px; + overflow: hidden; + background: #d7ebe4; +} + +.upload-progress-fill { + height: 100%; + width: 0%; + background: linear-gradient(90deg, #0f766e, #189181); + transition: width 140ms linear; +} + .table-wrap { width: 100%; overflow-x: auto; @@ -343,10 +436,18 @@ tbody tr:hover { padding-top: 1.3rem; } + .page-header { + padding: 0.8rem; + } + .panel { padding: 0.85rem; } + .dashboard-top-grid { + grid-template-columns: 1fr; + } + table { min-width: 620px; } diff --git a/nextjs/app/manage/_components/upload-progress-form.js b/nextjs/app/manage/_components/upload-progress-form.js new file mode 100644 index 0000000..ae28b61 --- /dev/null +++ b/nextjs/app/manage/_components/upload-progress-form.js @@ -0,0 +1,112 @@ +'use client'; + +import { useState } from 'react'; + +function parseErrorMessage(xhr) { + const response = xhr.response; + if (response && typeof response === 'object' && response.error) { + return String(response.error); + } + + try { + const parsed = JSON.parse(xhr.responseText || '{}'); + if (parsed && typeof parsed.error === 'string') { + return parsed.error; + } + } catch { + } + + return `Upload fehlgeschlagen (HTTP ${xhr.status}).`; +} + +export function UploadProgressForm({ csrfToken }) { + const [uploading, setUploading] = useState(false); + const [progress, setProgress] = useState(0); + const [localError, setLocalError] = useState(''); + + function handleSubmit(event) { + event.preventDefault(); + if (uploading) { + return; + } + + const form = event.currentTarget; + const formData = new FormData(form); + const uploadedFile = formData.get('file'); + + if (!uploadedFile || typeof uploadedFile === 'string' || !uploadedFile.size) { + setLocalError('Bitte zuerst eine Datei auswählen.'); + return; + } + + setUploading(true); + setProgress(0); + setLocalError(''); + + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/manage/api/upload', true); + xhr.responseType = 'json'; + xhr.setRequestHeader('x-csrf-token', csrfToken); + + xhr.upload.onprogress = (uploadEvent) => { + if (!uploadEvent.lengthComputable || uploadEvent.total <= 0) { + return; + } + const nextValue = Math.round((uploadEvent.loaded / uploadEvent.total) * 100); + setProgress(Math.max(0, Math.min(100, nextValue))); + }; + + xhr.onerror = () => { + setUploading(false); + setLocalError('Netzwerkfehler beim Upload.'); + }; + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + const redirectPath = xhr.response?.redirect || '/manage/dashboard?success=Upload%20abgeschlossen.'; + window.location.assign(redirectPath); + return; + } + + setUploading(false); + setProgress(0); + setLocalError(parseErrorMessage(xhr)); + }; + + xhr.send(formData); + } + + return ( +
+ ); +} diff --git a/nextjs/app/manage/admin/dashboard/page.js b/nextjs/app/manage/admin/dashboard/page.js index b92556e..89ed561 100644 --- a/nextjs/app/manage/admin/dashboard/page.js +++ b/nextjs/app/manage/admin/dashboard/page.js @@ -5,6 +5,7 @@ import { } 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 { formatBytes, formatCountdown, @@ -152,7 +153,8 @@ export default async function AdminDashboardPage({ searchParams }) { {allUploads.map((item) => { - const sharePath = `/_share/${encodeURIComponent(item.stored_name)}`; + const shareName = sharedLinkName(item.stored_name); + const sharePath = `/_share/${encodeURIComponent(shareName)}`; return (