export async function api(path, opts = {}) { const { body, ...rest } = opts const res = await fetch(path, { ...rest, headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined, body: body !== undefined ? JSON.stringify(body) : undefined, }) if (!res.ok) { let message = res.statusText try { message = (await res.json()).error || message } catch { /* not json */ } const err = new Error(message) err.status = res.status throw err } if (res.status === 204) return null return res.json() } // Image URL with a cache-buster tied to the last processing run, so // reprocessed photos bypass the long-lived immutable browser cache. // Auth rides on cookies (session or share-access), never in the URL. export function imgUrl(photo, size) { const version = photo.processed_at ? `?v=${encodeURIComponent(photo.processed_at)}` : '' return `/api/img/${photo.id}/${size}${version}` } // Trigger a browser-native download from a POST endpoint (e.g. zip streams) // via a hidden form — fetch+blob would buffer the whole file in memory. // Targets a hidden iframe so an error response can't navigate away from the // app (which would lose selection/rating state); errors surface as an alert. export function postDownload(url, ids = '') { let frame = document.getElementById('download-frame') if (!frame) { frame = document.createElement('iframe') frame.id = 'download-frame' frame.name = 'download-frame' frame.style.display = 'none' document.body.appendChild(frame) } frame.onload = () => { // load only fires when the response rendered (i.e. an error body); // successful attachment downloads never trigger it. let message = 'download failed' try { const text = frame.contentDocument?.body?.textContent if (!text) return try { message = JSON.parse(text).error || message } catch { /* not json */ } } catch { return } alert(`Download failed: ${message}`) } const form = document.createElement('form') form.method = 'POST' form.action = url form.target = 'download-frame' form.style.display = 'none' const input = document.createElement('input') input.type = 'hidden' input.name = 'ids' input.value = ids form.appendChild(input) document.body.appendChild(form) form.submit() form.remove() } // SHA-256 of a File, matching the server's content hash — used to skip // uploading bytes the album already has. export async function sha256Hex(file) { const digest = await crypto.subtle.digest('SHA-256', await file.arrayBuffer()) return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('') } export function uploadFile(url, file, onProgress) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() xhr.open('POST', url) xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream') xhr.upload.onprogress = (e) => { if (e.lengthComputable && onProgress) onProgress(e.loaded / e.total) } xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(JSON.parse(xhr.responseText)) } else { let message = `upload failed (${xhr.status})` try { message = JSON.parse(xhr.responseText).error || message } catch { /* not json */ } reject(new Error(message)) } } xhr.onerror = () => reject(new Error('network error during upload')) xhr.send(file) }) }