b26bef0296
Once a file was picked the preview <img> overlaid the file input and blocked further clicks, so the drop zone became read-only. Making the preview and the new 'Click to change' hint pointer-events:none lets clicks pass through to the input again. Also parses the JPEG APP1 EXIF segment inline (no dependency) to read DateTimeOriginal and prefill the date field. Falls back to the file's lastModified for PNGs or JPEGs without EXIF. The date field stays editable in either case.
285 lines
9.2 KiB
HTML
285 lines
9.2 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">
|
|
<style>
|
|
.upload-page {
|
|
max-width: 520px;
|
|
margin: 0 auto;
|
|
padding: 32px 20px;
|
|
}
|
|
.upload-page h2 {
|
|
font-family: var(--serif);
|
|
font-size: 22px;
|
|
font-weight: 600;
|
|
color: var(--fg);
|
|
margin-bottom: 28px;
|
|
}
|
|
.drop-zone {
|
|
border: 1px dashed var(--bg2);
|
|
background: var(--bg0);
|
|
width: 100%;
|
|
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); }
|
|
.drop-zone.has-image { border-style: solid; border-color: var(--bg2); }
|
|
.drop-zone input[type="file"] {
|
|
position: absolute;
|
|
inset: 0;
|
|
opacity: 0;
|
|
cursor: pointer;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
.drop-prompt {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 10px;
|
|
color: var(--fg4);
|
|
pointer-events: none;
|
|
}
|
|
.drop-prompt p { font-size: 14px; font-family: var(--serif); }
|
|
.drop-prompt small { font-size: 12px; font-family: monospace; }
|
|
#preview {
|
|
display: none;
|
|
position: absolute;
|
|
inset: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
pointer-events: none;
|
|
}
|
|
.change-hint {
|
|
display: none;
|
|
position: absolute;
|
|
inset: auto 0 0 0;
|
|
padding: 6px 10px;
|
|
background: rgba(29, 32, 33, 0.75);
|
|
color: var(--fg2);
|
|
font-family: monospace;
|
|
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>
|
|
</head>
|
|
<body>
|
|
<div class="upload-page">
|
|
<a href="/" class="back-btn">← Gallery</a>
|
|
<h2>Upload photo</h2>
|
|
|
|
<div class="drop-zone" id="drop-zone">
|
|
<input type="file" id="file-input" accept=".jpg,.jpeg,.png" aria-label="Choose photo">
|
|
<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">
|
|
<rect x="3" y="3" width="18" height="18" rx="1"/>
|
|
<circle cx="8.5" cy="8.5" r="1.5"/>
|
|
<path d="M21 15l-5-5L5 21"/>
|
|
</svg>
|
|
<p>Click to choose a photo</p>
|
|
<small>jpg or png</small>
|
|
</div>
|
|
<img id="preview" alt="Preview">
|
|
<div class="change-hint">Click to change</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>
|
|
|
|
<button class="submit-btn" id="submit-btn" disabled>Upload photo</button>
|
|
<div id="feedback"></div>
|
|
</div>
|
|
|
|
<script>
|
|
const fileInput = document.getElementById('file-input');
|
|
const preview = document.getElementById('preview');
|
|
const prompt = document.getElementById('prompt');
|
|
const dropZone = document.getElementById('drop-zone');
|
|
const submitBtn = document.getElementById('submit-btn');
|
|
const feedback = document.getElementById('feedback');
|
|
const dateInput = document.getElementById('date');
|
|
|
|
dateInput.value = new Date().toISOString().slice(0, 10);
|
|
|
|
// 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;
|
|
}
|
|
|
|
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);
|
|
|
|
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 = '';
|
|
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>
|
|
</body>
|
|
</html>
|