aboutsummaryrefslogtreecommitdiff
path: root/public
diff options
context:
space:
mode:
authordax <me@dax.ist>2026-08-04 18:53:03 +0100
committerdax <me@dax.ist>2026-08-04 18:53:03 +0100
commit247390000a0fdf7eeedb89a2c5500c64f36df1c3 (patch)
tree4ffdc7bc608518a2d489375b29d7969768f1a97f /public
parentdadf0909dd235a7764a59a63d2b51861fa81e385 (diff)
show upload progress and indexing state; raise upload caps and timeouts
Diffstat (limited to 'public')
-rw-r--r--public/app.css46
-rw-r--r--public/app.js52
-rw-r--r--public/index.html8
3 files changed, 100 insertions, 6 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>