2026-07-02 00:53:17 +02:00
|
|
|
<!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>
|
2026-07-02 02:02:12 +02:00
|
|
|
<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">
|
2026-07-02 00:53:17 +02:00
|
|
|
<link rel="stylesheet" href="/static/style.css">
|
|
|
|
|
<style>
|
|
|
|
|
.upload-page {
|
|
|
|
|
max-width: 520px;
|
|
|
|
|
margin: 0 auto;
|
|
|
|
|
padding: 32px 20px;
|
|
|
|
|
}
|
|
|
|
|
.upload-page h2 {
|
2026-07-02 02:02:12 +02:00
|
|
|
font-family: var(--serif);
|
2026-07-02 00:53:17 +02:00
|
|
|
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;
|
|
|
|
|
}
|
2026-07-02 02:02:12 +02:00
|
|
|
.drop-prompt p { font-size: 14px; font-family: var(--serif); }
|
2026-07-02 00:53:17 +02:00
|
|
|
.drop-prompt small { font-size: 12px; font-family: monospace; }
|
|
|
|
|
#preview {
|
|
|
|
|
display: none;
|
|
|
|
|
position: absolute;
|
|
|
|
|
inset: 0;
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
object-fit: cover;
|
2026-07-02 02:09:54 +02:00
|
|
|
pointer-events: none;
|
2026-07-02 00:53:17 +02:00
|
|
|
}
|
2026-07-02 02:09:54 +02:00
|
|
|
.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; }
|
2026-07-02 00:53:17 +02:00
|
|
|
.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;
|
|
|
|
|
}
|
2026-07-02 02:02:12 +02:00
|
|
|
.field input[type="text"] { font-family: var(--serif); }
|
2026-07-02 00:53:17 +02:00
|
|
|
.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);
|
2026-07-02 02:02:12 +02:00
|
|
|
font-family: var(--serif);
|
2026-07-02 00:53:17 +02:00
|
|
|
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">
|
2026-07-02 02:09:54 +02:00
|
|
|
<div class="change-hint">Click to change</div>
|
2026-07-02 00:53:17 +02:00
|
|
|
</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);
|
|
|
|
|
|
2026-07-02 02:09:54 +02:00
|
|
|
// 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 () => {
|
2026-07-02 00:53:17 +02:00
|
|
|
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';
|
2026-07-02 02:09:54 +02:00
|
|
|
|
|
|
|
|
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;
|
2026-07-02 00:53:17 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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>
|