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.
This commit is contained in:
1 parent
225bdc9b4f
commit
c21ddc5b7e
3 files changed
+198
-216
No files matched your search
@@ -19,7 +19,9 @@ The binary does three things at once:
|
|||||||
- Serves a multipart upload API at `POST /upload`
|
- Serves a multipart upload API at `POST /upload`
|
||||||
- In dev mode (`--serve`), also serves `public/` as static files
|
- In dev mode (`--serve`), also serves `public/` as static files
|
||||||
|
|
||||||
Routes: `GET /upload` (HTML upload form), `POST /upload` (multipart),
|
Routes: `GET /upload` (HTML upload form — multi-photo: the page posts one
|
||||||
|
`POST /upload` per selected photo, each with its own caption/date/tags),
|
||||||
|
`POST /upload` (multipart, one image per request),
|
||||||
`GET /manage` (edit/delete UI), the manage API under `/api/` (see Manage),
|
`GET /manage` (edit/delete UI), the manage API under `/api/` (see Manage),
|
||||||
`GET /health`. Every route except `/health` is gated by HTTP Basic auth when
|
`GET /health`. Every route except `/health` is gated by HTTP Basic auth when
|
||||||
`-htpasswd` is set (see Auth).
|
`-htpasswd` is set (see Auth).
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ database, no framework.
|
|||||||
|
|
||||||
**Content & management (no database)**
|
**Content & management (no database)**
|
||||||
- `.toml` sidecars are the source of truth; the binary watches the content dir and rebuilds on any change. Four renditions per photo (original, 600² thumbnail, 1600px medium, 1200px OG), each skipped when already up to date.
|
- `.toml` sidecars are the source of truth; the binary watches the content dir and rebuilds on any change. Four renditions per photo (original, 600² thumbnail, 1600px medium, 1200px OG), each skipped when already up to date.
|
||||||
- **Web upload** (`/upload`) with EXIF-date prefill and tag autocomplete.
|
- **Web upload** (`/upload`): pick one or many photos, each with its own EXIF-prefilled date, caption and tag-autocompleting tags field; posted with per-photo status and retry.
|
||||||
- **Manage UI** (`/manage`): edit caption/date/tags, bulk tag add/remove, and delete — delete is reversible (moves to `content/.trash/`, with restore / empty).
|
- **Manage UI** (`/manage`): edit caption/date/tags, bulk tag add/remove, and delete — delete is reversible (moves to `content/.trash/`, with restore / empty).
|
||||||
- Tag autocomplete across the upload and manage forms, fed by `/tags.json`.
|
- Tag autocomplete across the upload and manage forms, fed by `/tags.json`.
|
||||||
|
|
||||||
|
|||||||
+194
-214
@@ -10,177 +10,219 @@
|
|||||||
<link rel="icon" type="image/png" href="/static/web-32.png" sizes="32x32">
|
<link rel="icon" type="image/png" href="/static/web-32.png" sizes="32x32">
|
||||||
<link rel="apple-touch-icon" href="/static/web-192.png">
|
<link rel="apple-touch-icon" href="/static/web-192.png">
|
||||||
<style>
|
<style>
|
||||||
.upload-page {
|
.upload-page { max-width: 640px; margin: 0 auto; padding: 32px 20px 80px; }
|
||||||
max-width: 520px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 32px 20px;
|
|
||||||
}
|
|
||||||
.upload-page h2 {
|
.upload-page h2 {
|
||||||
font-family: var(--serif);
|
font-family: var(--serif); font-size: 22px; font-weight: 600;
|
||||||
font-size: 22px;
|
color: var(--fg); margin-bottom: 20px;
|
||||||
font-weight: 600;
|
|
||||||
color: var(--fg);
|
|
||||||
margin-bottom: 28px;
|
|
||||||
}
|
}
|
||||||
.drop-zone {
|
.pick-btn, .submit-btn {
|
||||||
border: 1px dashed var(--bg2);
|
width: 100%; background: var(--bg1); border: 1px solid var(--bg2);
|
||||||
background: var(--bg0);
|
color: var(--fg2); font-family: var(--serif); font-size: 16px; font-weight: 600;
|
||||||
width: 100%;
|
padding: 12px; cursor: pointer; transition: background .2s, color .2s;
|
||||||
aspect-ratio: 1;
|
|
||||||
max-height: 340px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
}
|
}
|
||||||
.drop-zone:hover { border-color: var(--fg4); }
|
.pick-btn:hover:not(:disabled), .submit-btn:hover:not(:disabled) { background: var(--bg2); color: var(--fg); }
|
||||||
.drop-zone.has-image { border-style: solid; border-color: var(--bg2); }
|
.submit-btn:disabled { opacity: .35; cursor: not-allowed; }
|
||||||
.drop-zone input[type="file"] {
|
.pick-btn { margin-bottom: 20px; }
|
||||||
position: absolute;
|
#file-input { display: none; }
|
||||||
inset: 0;
|
|
||||||
opacity: 0;
|
#empty {
|
||||||
cursor: pointer;
|
display: flex; align-items: center; justify-content: center;
|
||||||
width: 100%;
|
height: 140px; background: var(--bg0); color: var(--fg4);
|
||||||
height: 100%;
|
font-family: var(--serif); margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
.drop-prompt {
|
|
||||||
display: flex;
|
.card { display: grid; grid-template-columns: 84px 1fr auto; gap: 14px; align-items: start; padding: 14px 0; border-top: 1px solid var(--bg1); }
|
||||||
flex-direction: column;
|
.card .thumb { width: 84px; height: 84px; background: var(--bg1); object-fit: cover; display: block; }
|
||||||
align-items: center;
|
.card .fields { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
|
||||||
gap: 10px;
|
.card input {
|
||||||
color: var(--fg4);
|
width: 100%; box-sizing: border-box; background: var(--bg0);
|
||||||
pointer-events: none;
|
border: 1px solid var(--bg2); color: var(--fg); font-size: 15px; padding: 8px 10px; outline: none;
|
||||||
}
|
}
|
||||||
.drop-prompt p { font-size: 14px; font-family: var(--serif); }
|
.card input[type="text"] { font-family: var(--serif); }
|
||||||
.drop-prompt small { font-size: 12px; font-family: monospace; }
|
.card input[type="date"] { font-family: monospace; font-size: 13px; color-scheme: dark; width: auto; }
|
||||||
#preview {
|
.card input:focus { border-color: var(--fg4); }
|
||||||
display: none;
|
.card input:disabled { opacity: .5; }
|
||||||
position: absolute;
|
.c-remove {
|
||||||
inset: 0;
|
background: none; border: none; color: var(--fg4); font-size: 16px; cursor: pointer; padding: 4px;
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
.change-hint {
|
.c-status { font-family: monospace; font-size: 12px; min-height: 16px; }
|
||||||
display: none;
|
.c-status.ok { color: #b8bb26; }
|
||||||
position: absolute;
|
.c-status.err { color: #fb4934; }
|
||||||
inset: auto 0 0 0;
|
.c-status.up { color: var(--fg4); }
|
||||||
padding: 6px 10px;
|
.c-status a { color: #8ec07c; }
|
||||||
background: rgba(29, 32, 33, 0.75);
|
|
||||||
color: var(--fg2);
|
#bar { margin-top: 20px; }
|
||||||
font-family: monospace;
|
#summary { margin-top: 12px; font-family: monospace; font-size: 13px; color: var(--fg4); }
|
||||||
font-size: 11px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-align: center;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.drop-zone.has-image .change-hint { display: block; }
|
|
||||||
.field { margin-bottom: 18px; }
|
|
||||||
.field label {
|
|
||||||
display: block;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--fg4);
|
|
||||||
font-family: monospace;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
.field input[type="text"],
|
|
||||||
.field input[type="date"] {
|
|
||||||
width: 100%;
|
|
||||||
background: var(--bg0);
|
|
||||||
border: 1px solid var(--bg2);
|
|
||||||
color: var(--fg);
|
|
||||||
font-size: 16px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
}
|
|
||||||
.field input[type="text"] { font-family: var(--serif); }
|
|
||||||
.field input[type="date"] { font-family: monospace; font-size: 14px; color-scheme: dark; }
|
|
||||||
.field input:focus { border-color: var(--fg4); }
|
|
||||||
.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 0.2s, color 0.2s;
|
|
||||||
}
|
|
||||||
.submit-btn:hover:not(:disabled) { background: var(--bg2); color: var(--fg); }
|
|
||||||
.submit-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
|
||||||
#feedback {
|
|
||||||
display: none;
|
|
||||||
margin-top: 14px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: monospace;
|
|
||||||
padding: 10px 12px;
|
|
||||||
}
|
|
||||||
#feedback.ok { display: block; background: #1d2e1d; color: #b8bb26; border: 1px solid #344d28; }
|
|
||||||
#feedback.err { display: block; background: #2e1d1d; color: #fb4934; border: 1px solid #5a2828; }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="upload-page">
|
<div class="upload-page">
|
||||||
<a href="/" class="back-btn">← Gallery</a>
|
<a href="/" class="back-btn">← Gallery</a>
|
||||||
<h2>Upload photo</h2>
|
<h2>Upload photos</h2>
|
||||||
|
|
||||||
<div class="drop-zone" id="drop-zone">
|
<input type="file" id="file-input" accept=".jpg,.jpeg,.png" multiple>
|
||||||
<input type="file" id="file-input" accept=".jpg,.jpeg,.png" aria-label="Choose photo">
|
<button class="pick-btn" id="pick-btn">Choose photos</button>
|
||||||
<div class="drop-prompt" id="prompt">
|
|
||||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" aria-hidden="true">
|
<div id="empty">No photos selected</div>
|
||||||
<rect x="3" y="3" width="18" height="18" rx="1"/>
|
<div id="list"></div>
|
||||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
|
||||||
<path d="M21 15l-5-5L5 21"/>
|
<div id="bar" style="display:none">
|
||||||
</svg>
|
<button class="submit-btn" id="post-btn">Post photos</button>
|
||||||
<p>Click to choose a photo</p>
|
<div id="summary"></div>
|
||||||
<small>jpg or png</small>
|
|
||||||
</div>
|
|
||||||
<img id="preview" alt="Preview">
|
|
||||||
<div class="change-hint">Click to change</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label for="caption">Caption</label>
|
|
||||||
<input type="text" id="caption" placeholder="Describe the photo" autocomplete="off">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label for="date">Date</label>
|
|
||||||
<input type="date" id="date">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label for="tags">Tags</label>
|
|
||||||
<input type="text" id="tags" placeholder="comma-separated, lowercase" autocomplete="off" data-tagcomplete>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="submit-btn" id="submit-btn" disabled>Upload photo</button>
|
|
||||||
<div id="feedback"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/tagcomplete.js"></script>
|
<script src="/static/tagcomplete.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const fileInput = document.getElementById('file-input');
|
const fileInput = document.getElementById('file-input');
|
||||||
const preview = document.getElementById('preview');
|
const pickBtn = document.getElementById('pick-btn');
|
||||||
const prompt = document.getElementById('prompt');
|
const list = document.getElementById('list');
|
||||||
const dropZone = document.getElementById('drop-zone');
|
const emptyEl = document.getElementById('empty');
|
||||||
const submitBtn = document.getElementById('submit-btn');
|
const bar = document.getElementById('bar');
|
||||||
const feedback = document.getElementById('feedback');
|
const postBtn = document.getElementById('post-btn');
|
||||||
const dateInput = document.getElementById('date');
|
const summary = document.getElementById('summary');
|
||||||
|
|
||||||
dateInput.value = new Date().toISOString().slice(0, 10);
|
// 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"
|
// 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.
|
// or null if the file isn't a JPEG, has no EXIF, or lacks the tag.
|
||||||
@@ -228,68 +270,6 @@
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
fileInput.addEventListener('change', async () => {
|
|
||||||
const file = fileInput.files[0];
|
|
||||||
if (!file) return;
|
|
||||||
const url = URL.createObjectURL(file);
|
|
||||||
preview.onload = () => URL.revokeObjectURL(url);
|
|
||||||
preview.src = url;
|
|
||||||
preview.style.display = 'block';
|
|
||||||
prompt.style.display = 'none';
|
|
||||||
dropZone.classList.add('has-image');
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
feedback.className = '';
|
|
||||||
feedback.style.display = 'none';
|
|
||||||
|
|
||||||
let d = null;
|
|
||||||
try { d = await readExifDate(file); } catch (_) {}
|
|
||||||
if (!d && file.lastModified) {
|
|
||||||
d = new Date(file.lastModified).toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
if (d) dateInput.value = d;
|
|
||||||
});
|
|
||||||
|
|
||||||
submitBtn.addEventListener('click', async () => {
|
|
||||||
const file = fileInput.files[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
submitBtn.textContent = 'Uploading…';
|
|
||||||
feedback.className = '';
|
|
||||||
|
|
||||||
const form = new FormData();
|
|
||||||
form.append('image', file);
|
|
||||||
form.append('caption', document.getElementById('caption').value);
|
|
||||||
form.append('date', dateInput.value);
|
|
||||||
form.append('tags', document.getElementById('tags').value);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/upload', { method: 'POST', body: form });
|
|
||||||
const text = (await res.text()).trim();
|
|
||||||
if (res.ok) {
|
|
||||||
feedback.className = 'ok';
|
|
||||||
feedback.textContent = '✓ ' + text;
|
|
||||||
fileInput.value = '';
|
|
||||||
preview.style.display = 'none';
|
|
||||||
prompt.style.display = 'flex';
|
|
||||||
dropZone.classList.remove('has-image');
|
|
||||||
document.getElementById('caption').value = '';
|
|
||||||
document.getElementById('tags').value = '';
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
} else {
|
|
||||||
feedback.className = 'err';
|
|
||||||
feedback.textContent = '✗ ' + text;
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
feedback.className = 'err';
|
|
||||||
feedback.textContent = '✗ ' + err.message;
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
submitBtn.textContent = 'Upload photo';
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/session.js"></script>
|
<script src="/static/session.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in new issue
Block a user