Files
photog/templates/upload.html
T
puttaalu c21ddc5b7e Add bulk (multi-photo) upload to the web form
The /upload page now takes several photos at once, mirroring the Android and Qt
apps: a card per photo with its own preview, EXIF-prefilled date, caption and
tag-autocompleting tags field, a remove button, and per-photo status. "Post
photos" uploads them sequentially with an x/N progress line; failures can be
retried by pressing Post again, successes link straight to the new photo page.

No server change — each photo is still a separate POST /upload, exactly as the
apps' per-photo queue does it, so slugs come from filenames as before.
2026-10-18 23:29:04 +02:00

277 lines
11 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Upload — Photo Gallery</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Crimson+Pro:ital,wght@0,400;0,600;0,700;1,400&family=Manjari:wght@100;400;700&display=swap">
<link rel="stylesheet" href="/static/style.css">
<link rel="icon" href="/static/favicon.ico" sizes="any">
<link rel="icon" type="image/png" href="/static/web-32.png" sizes="32x32">
<link rel="apple-touch-icon" href="/static/web-192.png">
<style>
.upload-page { max-width: 640px; margin: 0 auto; padding: 32px 20px 80px; }
.upload-page h2 {
font-family: var(--serif); font-size: 22px; font-weight: 600;
color: var(--fg); margin-bottom: 20px;
}
.pick-btn, .submit-btn {
width: 100%; background: var(--bg1); border: 1px solid var(--bg2);
color: var(--fg2); font-family: var(--serif); font-size: 16px; font-weight: 600;
padding: 12px; cursor: pointer; transition: background .2s, color .2s;
}
.pick-btn:hover:not(:disabled), .submit-btn:hover:not(:disabled) { background: var(--bg2); color: var(--fg); }
.submit-btn:disabled { opacity: .35; cursor: not-allowed; }
.pick-btn { margin-bottom: 20px; }
#file-input { display: none; }
#empty {
display: flex; align-items: center; justify-content: center;
height: 140px; background: var(--bg0); color: var(--fg4);
font-family: var(--serif); margin-bottom: 20px;
}
.card { display: grid; grid-template-columns: 84px 1fr auto; gap: 14px; align-items: start; padding: 14px 0; border-top: 1px solid var(--bg1); }
.card .thumb { width: 84px; height: 84px; background: var(--bg1); object-fit: cover; display: block; }
.card .fields { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.card input {
width: 100%; box-sizing: border-box; background: var(--bg0);
border: 1px solid var(--bg2); color: var(--fg); font-size: 15px; padding: 8px 10px; outline: none;
}
.card input[type="text"] { font-family: var(--serif); }
.card input[type="date"] { font-family: monospace; font-size: 13px; color-scheme: dark; width: auto; }
.card input:focus { border-color: var(--fg4); }
.card input:disabled { opacity: .5; }
.c-remove {
background: none; border: none; color: var(--fg4); font-size: 16px; cursor: pointer; padding: 4px;
}
.c-status { font-family: monospace; font-size: 12px; min-height: 16px; }
.c-status.ok { color: #b8bb26; }
.c-status.err { color: #fb4934; }
.c-status.up { color: var(--fg4); }
.c-status a { color: #8ec07c; }
#bar { margin-top: 20px; }
#summary { margin-top: 12px; font-family: monospace; font-size: 13px; color: var(--fg4); }
</style>
</head>
<body>
<div class="upload-page">
<a href="/" class="back-btn">← Gallery</a>
<h2>Upload photos</h2>
<input type="file" id="file-input" accept=".jpg,.jpeg,.png" multiple>
<button class="pick-btn" id="pick-btn">Choose photos</button>
<div id="empty">No photos selected</div>
<div id="list"></div>
<div id="bar" style="display:none">
<button class="submit-btn" id="post-btn">Post photos</button>
<div id="summary"></div>
</div>
</div>
<script src="/static/tagcomplete.js"></script>
<script>
const fileInput = document.getElementById('file-input');
const pickBtn = document.getElementById('pick-btn');
const list = document.getElementById('list');
const emptyEl = document.getElementById('empty');
const bar = document.getElementById('bar');
const postBtn = document.getElementById('post-btn');
const summary = document.getElementById('summary');
// One entry per chosen photo. status: pending | uploading | ok | error.
let items = [];
let seq = 0;
let posting = false;
pickBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => {
for (const file of fileInput.files) addItem(file);
fileInput.value = ''; // allow re-choosing the same file
refresh();
});
function addItem(file) {
const item = { id: ++seq, file, status: 'pending', el: null };
items.push(item);
item.el = buildCard(item);
list.appendChild(item.el);
// Preview.
const url = URL.createObjectURL(file);
const img = item.el.querySelector('.thumb');
img.onload = () => URL.revokeObjectURL(url);
img.src = url;
// Prefill date from EXIF, else file mtime.
prefillDate(item, file);
// Tag autocomplete on this card's tags field.
if (window.TagComplete) window.TagComplete.attach(item.el.querySelector('.c-tags'));
}
function buildCard(item) {
const el = document.createElement('div');
el.className = 'card';
el.innerHTML = `
<img class="thumb" alt="">
<div class="fields">
<input type="text" class="c-caption" placeholder="Caption" autocomplete="off">
<input type="date" class="c-date">
<input type="text" class="c-tags" placeholder="tags, comma-separated" autocomplete="off">
<div class="c-status"></div>
</div>
<button class="c-remove" title="Remove">✕</button>`;
el.querySelector('.c-remove').addEventListener('click', () => removeItem(item.id));
return el;
}
async function prefillDate(item, file) {
let d = null;
try { d = await readExifDate(file); } catch (_) {}
if (!d && file.lastModified) d = new Date(file.lastModified).toISOString().slice(0, 10);
if (!d) d = new Date().toISOString().slice(0, 10);
item.el.querySelector('.c-date').value = d;
}
function removeItem(id) {
const item = items.find((i) => i.id === id);
if (!item || item.status === 'uploading') return;
item.el.remove();
items = items.filter((i) => i.id !== id);
refresh();
}
function refresh() {
const has = items.length > 0;
emptyEl.style.display = has ? 'none' : 'flex';
bar.style.display = has ? 'block' : 'none';
pickBtn.textContent = has ? 'Add more photos' : 'Choose photos';
updateButton();
}
function pending() { return items.filter((i) => i.status !== 'ok'); }
function updateButton() {
const n = pending().length;
postBtn.disabled = posting || n === 0;
postBtn.textContent = posting
? 'Posting…'
: n === 0 ? 'All posted' : (n === 1 ? 'Post 1 photo' : `Post ${n} photos`);
}
function setStatus(item, cls, html) {
const s = item.el.querySelector('.c-status');
s.className = 'c-status ' + cls;
s.innerHTML = html;
}
function setDisabled(item, disabled) {
item.el.querySelectorAll('input').forEach((i) => (i.disabled = disabled));
item.el.querySelector('.c-remove').disabled = disabled;
}
async function postOne(item) {
item.status = 'uploading';
setDisabled(item, true);
setStatus(item, 'up', 'Uploading…');
const form = new FormData();
form.append('image', item.file);
form.append('caption', item.el.querySelector('.c-caption').value);
form.append('date', item.el.querySelector('.c-date').value);
form.append('tags', item.el.querySelector('.c-tags').value);
try {
const res = await fetch('/upload', { method: 'POST', body: form });
const text = (await res.text()).trim();
if (res.ok) {
item.status = 'ok';
const slug = text.replace(/^uploaded\s+/, '').replace(/\.[^.]+$/, '');
setStatus(item, 'ok', '✓ posted · <a href="/photo/' + encodeURIComponent(slug) + '/" target="_blank" rel="noopener">view</a>');
} else {
item.status = 'error';
setDisabled(item, false);
setStatus(item, 'err', '✗ ' + (text || res.status));
}
} catch (err) {
item.status = 'error';
setDisabled(item, false);
setStatus(item, 'err', '✗ ' + err.message);
}
}
postBtn.addEventListener('click', async () => {
if (posting) return;
posting = true;
updateButton();
if (window.TagComplete) window.TagComplete.invalidate();
// Sequential: gentler on mobile connections, and the rebuild is debounced
// server-side anyway.
const todo = pending();
let done = 0;
for (const item of todo) {
await postOne(item);
done++;
summary.textContent = `${done}/${todo.length} processed`;
}
posting = false;
const ok = items.filter((i) => i.status === 'ok').length;
const failed = items.filter((i) => i.status === 'error').length;
summary.textContent = `${ok} posted` + (failed ? `, ${failed} failed (press Post to retry)` : '');
updateButton();
});
// Read DateTimeOriginal from a JPEG's EXIF APP1 segment. Returns "YYYY-MM-DD"
// or null if the file isn't a JPEG, has no EXIF, or lacks the tag.
async function readExifDate(file) {
if (!/jpe?g$/i.test(file.type) && !/\.jpe?g$/i.test(file.name)) return null;
const buf = await file.slice(0, 65536).arrayBuffer();
const view = new DataView(buf);
if (view.getUint16(0) !== 0xFFD8) return null;
let offset = 2;
while (offset + 4 < view.byteLength) {
const marker = view.getUint16(offset);
if (marker < 0xFF00) return null;
const size = view.getUint16(offset + 2);
if (marker === 0xFFE1 && view.getUint32(offset + 4) === 0x45786966) {
const tiff = offset + 10;
const little = view.getUint16(tiff) === 0x4949;
const get16 = (o) => view.getUint16(o, little);
const get32 = (o) => view.getUint32(o, little);
const ifd0 = tiff + get32(tiff + 4);
let exifIfd = 0;
const n = get16(ifd0);
for (let i = 0; i < n; i++) {
const e = ifd0 + 2 + i * 12;
if (get16(e) === 0x8769) { exifIfd = tiff + get32(e + 8); break; }
}
if (!exifIfd) return null;
const en = get16(exifIfd);
for (let i = 0; i < en; i++) {
const e = exifIfd + 2 + i * 12;
if (get16(e) === 0x9003) {
const count = get32(e + 4);
const dataOff = count > 4 ? tiff + get32(e + 8) : e + 8;
let s = '';
for (let k = 0; k < count - 1; k++) s += String.fromCharCode(view.getUint8(dataOff + k));
const m = s.match(/^(\d{4}):(\d{2}):(\d{2})/);
return m ? `${m[1]}-${m[2]}-${m[3]}` : null;
}
}
return null;
}
offset += 2 + size;
}
return null;
}
</script>
<script src="/static/session.js"></script>
</body>
</html>