diff options
| -rw-r--r-- | public/app.css | 46 | ||||
| -rw-r--r-- | public/app.js | 52 | ||||
| -rw-r--r-- | public/index.html | 8 | ||||
| -rw-r--r-- | server.mjs | 10 |
4 files changed, 107 insertions, 9 deletions
diff --git a/public/app.css b/public/app.css index 24bb927..153c334 100644 --- a/public/app.css +++ b/public/app.css @@ -258,6 +258,52 @@ html, body { #toast.show { opacity: 1; visibility: visible; pointer-events: auto; } #toast.err { border-color: rgba(248, 113, 113, .6); color: #fca5a5; } +#progress { + position: fixed; + left: 50%; + bottom: 26px; + transform: translateX(-50%); + z-index: 40; + display: flex; + flex-direction: column; + align-items: center; + gap: 9px; + min-width: 280px; + max-width: 70vw; + padding: 13px 18px; + border: 1px solid rgba(255, 255, 255, .18); + border-radius: 10px; + background: rgba(14, 15, 19, .95); +} +#progress[hidden] { display: none; } +#progress-track { + width: 100%; + height: 6px; + border-radius: 3px; + background: rgba(255, 255, 255, .12); + overflow: hidden; +} +#progress-fill { + height: 100%; + width: 0%; + border-radius: 3px; + background: var(--ring); + transition: width .15s ease; +} +#progress.indeterminate #progress-fill { + width: 40%; + animation: progress-slide 1.1s linear infinite; +} +@keyframes progress-slide { + 0% { transform: translateX(-110%); } + 100% { transform: translateX(360%); } +} +#progress-text { + font-size: 12px; + letter-spacing: .04em; + color: var(--muted); +} + @media (prefers-reduced-motion: reduce) { #world { transition: none; } .tile.lock { animation: none; } diff --git a/public/app.js b/public/app.js index dfd6e6b..78584bf 100644 --- a/public/app.js +++ b/public/app.js @@ -8,6 +8,9 @@ const importEl = $("#import") const fileInput = $("#file") const dropOverlay = $("#drop") const toastEl = $("#toast") +const progressEl = $("#progress") +const progressFill = $("#progress-fill") +const progressText = $("#progress-text") addEventListener("error", (e) => { toast(`error: ${e.message || "script failed"}`, true) @@ -708,23 +711,64 @@ function plural(n) { return n === 1 ? "photo" : "photos" } +function setProgress(mode, pct, text) { + progressEl.hidden = false + progressText.textContent = text + if (mode === "indeterminate") { + progressEl.classList.add("indeterminate") + } else { + progressEl.classList.remove("indeterminate") + progressFill.style.width = pct + "%" + } +} + +function hideProgress() { + progressEl.hidden = true + progressEl.classList.remove("indeterminate") + progressFill.style.width = "0%" +} + +function uploadWithProgress(form, onPct, onUploaded) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.open("POST", "/api/import") + xhr.upload.addEventListener("progress", (e) => { + if (e.lengthComputable) onPct(Math.min(100, Math.round((e.loaded / e.total) * 100))) + }) + xhr.upload.addEventListener("load", () => onUploaded()) + xhr.addEventListener("load", () => { + let data = {} + try { data = JSON.parse(xhr.responseText) } catch { /* ignore */ } + if (xhr.status >= 200 && xhr.status < 300) resolve(data) + else reject(new Error(data.error || `import failed (${xhr.status})`)) + }) + xhr.addEventListener("error", () => reject(new Error("upload failed (network error)"))) + xhr.send(form) + }) +} + async function importFiles(list) { const files = Array.from(list || []).filter((f) => f.size > 0) if (!files.length) return const form = new FormData() for (const f of files) form.append("photos", f, f.name) importEl.classList.add("busy") - toast(`importing ${files.length} ${plural(files.length)}...`) + setProgress("determinate", 0, `uploading ${files.length} ${plural(files.length)}… 0%`) try { - const r = await fetch("/api/import", { method: "POST", body: form }) - const data = await r.json().catch(() => ({})) - if (!r.ok || !data.ok) throw new Error(data.error || `import failed (${r.status})`) + const data = await uploadWithProgress( + form, + (pct) => setProgress("determinate", pct, `uploading ${files.length} ${plural(files.length)}… ${pct}%`), + () => setProgress("indeterminate", 100, "indexing…") + ) + if (!data.ok) throw new Error(data.error || "import failed") exitsCache.clear() exitPending.clear() + hideProgress() hideImportState() await beginWalk() toast(`imported ${data.imported} ${plural(data.imported)} - keep walking to find them`) } catch (err) { + hideProgress() toast(err.message || "import failed", true) } finally { importEl.classList.remove("busy") diff --git a/public/index.html b/public/index.html index 0f44c6b..ae56eea 100644 --- a/public/index.html +++ b/public/index.html @@ -5,7 +5,7 @@ <meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="color-scheme" content="dark"> <title>pathways</title> - <link rel="stylesheet" href="/app.css?v=43"> + <link rel="stylesheet" href="/app.css?v=44"> </head> <body> <main id="stage"> @@ -23,7 +23,11 @@ </main> <input id="file" type="file" accept="image/jpeg,image/png,image/webp" multiple> <div id="drop"><span>drop photos to import</span></div> + <div id="progress" hidden> + <div id="progress-track"><div id="progress-fill"></div></div> + <span id="progress-text"></span> + </div> <div id="toast" role="status" aria-live="polite"></div> - <script src="/app.js?v=43"></script> + <script src="/app.js?v=44"></script> </body> </html> @@ -28,9 +28,9 @@ const PHOTOPRISM_USER = process.env.PHOTOPRISM_USER || "photoprism" const PHOTOPRISM_HOME = process.env.PHOTOPRISM_HOME || "/srv/photoprism" const ORIGINALS_PATH = process.env.PHOTOPRISM_ORIGINALS_PATH || "/srv/photoprism/originals" const STORAGE_PATH = process.env.PHOTOPRISM_STORAGE_PATH || "/srv/photoprism/storage" -const MAX_UPLOAD_BYTES = Number(process.env.MAX_UPLOAD_BYTES || 200 * 1024 * 1024) -const MAX_UPLOAD_FILES = Number(process.env.MAX_UPLOAD_FILES || 20) -const IMPORT_TIMEOUT_MS = Number(process.env.IMPORT_TIMEOUT_MS || 10 * 60 * 1000) +const MAX_UPLOAD_BYTES = Number(process.env.MAX_UPLOAD_BYTES || 1024 * 1024 * 1024) +const MAX_UPLOAD_FILES = Number(process.env.MAX_UPLOAD_FILES || 100) +const IMPORT_TIMEOUT_MS = Number(process.env.IMPORT_TIMEOUT_MS || 30 * 60 * 1000) const pool = mysql.createPool(DB) @@ -401,6 +401,10 @@ const server = http.createServer(async (req, res) => { } }) +server.requestTimeout = 0 +server.headersTimeout = 65_000 +server.keepAliveTimeout = 65_000 + server.listen(PORT, "127.0.0.1", () => { console.log(`pathways listening on 127.0.0.1:${PORT}`) mkdir(STAGING_ROOT, { recursive: true }).catch((err) => console.error("pathways: staging dir:", err.message)) |
