Allow reselecting the upload photo and prefill date from EXIF

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.
This commit is contained in:
2026-07-02 02:09:54 +02:00
parent fcedf7e06a
commit b26bef0296
+72 -1
View File
@@ -62,7 +62,23 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; 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 { margin-bottom: 18px; }
.field label { .field label {
display: block; display: block;
@@ -129,6 +145,7 @@
<small>jpg or png</small> <small>jpg or png</small>
</div> </div>
<img id="preview" alt="Preview"> <img id="preview" alt="Preview">
<div class="change-hint">Click to change</div>
</div> </div>
<div class="field"> <div class="field">
@@ -156,7 +173,54 @@
dateInput.value = new Date().toISOString().slice(0, 10); dateInput.value = new Date().toISOString().slice(0, 10);
fileInput.addEventListener('change', () => { // 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]; const file = fileInput.files[0];
if (!file) return; if (!file) return;
const url = URL.createObjectURL(file); const url = URL.createObjectURL(file);
@@ -168,6 +232,13 @@
submitBtn.disabled = false; submitBtn.disabled = false;
feedback.className = ''; feedback.className = '';
feedback.style.display = 'none'; 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 () => { submitBtn.addEventListener('click', async () => {