Initial JPEG photo metadata editor v0.1

This commit is contained in:
dmo committed 2026-07-23 01:10:49 +02:00
1 parent cdc206680c
commit 8738b8c814
12 files changed
+1222

No files matched your search

+489
View File
@@ -0,0 +1,489 @@
const state = {
directoryHandle: null,
photos: [],
currentIndex: -1,
objectUrl: null,
thumbUrls: new Map(),
zoom: 1,
rotation: 0,
savedValues: new Map(),
processing: false,
};
const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => [...document.querySelectorAll(selector)];
const elements = {
appName: $('#app-name'),
openButtons: [$('#open-folder'), $('#open-folder-top'), $('#open-folder-center')],
rescan: $('#rescan-folder'),
folderName: $('#folder-name'),
folderAccess: $('#folder-access'),
progressCount: $('#progress-count'),
progressBar: $('#progress-bar'),
photoCount: $('#photo-count'),
filter: $('#filter-input'),
fileList: $('#file-list'),
viewerEmpty: $('#viewer-empty'),
viewerContent: $('#viewer-content'),
preview: $('#photo-preview'),
filename: $('#current-filename'),
filesize: $('#current-filesize'),
position: $('#viewer-position'),
previous: $('#previous-button'),
skip: $('#skip-button'),
save: $('#save-button'),
saveNext: $('#save-next-button'),
copyPrevious: $('#copy-previous'),
zoomOut: $('#zoom-out'),
zoomIn: $('#zoom-in'),
fit: $('#fit-image'),
rotateLeft: $('#rotate-left'),
rotateRight: $('#rotate-right'),
resetView: $('#reset-view'),
form: $('#metadata-form'),
day: $('#day-input'),
month: $('#month-input'),
year: $('#year-input'),
timeMode: $('#time-mode'),
timeField: $('#time-field'),
time: $('#time-input'),
description: $('#description-input'),
descriptionCount: $('#description-count'),
keywords: $('#keywords-input'),
saveStatus: $('#save-status'),
shortcutsButton: $('#shortcuts-button'),
shortcutsDialog: $('#shortcuts-dialog'),
closeShortcuts: $('#close-shortcuts'),
toast: $('#toast'),
};
function isSupported() {
return 'showDirectoryPicker' in window && window.isSecureContext;
}
function showToast(message, isError = false) {
elements.toast.textContent = message;
elements.toast.classList.toggle('error', isError);
elements.toast.classList.add('show');
clearTimeout(showToast.timer);
showToast.timer = setTimeout(() => elements.toast.classList.remove('show'), 3000);
}
function setSaveStatus(kind, title, detail) {
elements.saveStatus.className = `save-status ${kind}`;
elements.saveStatus.innerHTML = `<span class="status-icon">●</span><div><strong>${escapeHtml(title)}</strong><span>${escapeHtml(detail)}</span></div>`;
}
function escapeHtml(value) {
return String(value).replace(/[&<>'"]/g, (char) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'
})[char]);
}
function naturalCompare(a, b) {
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
}
function formatBytes(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
}
function storageKey() {
return state.directoryHandle ? `photo-date-editor:${state.directoryHandle.name}` : null;
}
function loadFolderState() {
const key = storageKey();
if (!key) return {};
try { return JSON.parse(localStorage.getItem(key) || '{}'); }
catch { return {}; }
}
function saveFolderState() {
const key = storageKey();
if (!key) return;
const photoState = {};
for (const photo of state.photos) {
photoState[photo.name] = {
status: photo.status,
values: photo.values || null,
};
}
localStorage.setItem(key, JSON.stringify({ photos: photoState }));
}
async function openFolder() {
if (!isSupported()) {
showToast('Use current Chrome or Edge over HTTPS. Folder access is unavailable here.', true);
return;
}
try {
const handle = await window.showDirectoryPicker({ mode: 'readwrite', id: 'photo-date-editor' });
const permission = await handle.requestPermission({ mode: 'readwrite' });
if (permission !== 'granted') throw new Error('Read/write permission was not granted.');
state.directoryHandle = handle;
await scanFolder();
} catch (error) {
if (error.name !== 'AbortError') showToast(error.message || 'Could not open the folder.', true);
}
}
async function scanFolder() {
if (!state.directoryHandle) return;
clearObjectUrls();
const stored = loadFolderState();
const photos = [];
for await (const [name, handle] of state.directoryHandle.entries()) {
if (handle.kind !== 'file' || !/\.(jpe?g)$/i.test(name)) continue;
const file = await handle.getFile();
const previous = stored.photos?.[name] || {};
photos.push({
name,
handle,
size: file.size,
lastModified: file.lastModified,
status: previous.status || 'pending',
values: previous.values || null,
});
}
photos.sort((a, b) => naturalCompare(a.name, b.name));
state.photos = photos;
state.currentIndex = photos.length ? 0 : -1;
elements.folderName.textContent = state.directoryHandle.name;
elements.folderAccess.textContent = 'Read/write access granted';
elements.filter.disabled = !photos.length;
elements.rescan.disabled = false;
elements.photoCount.textContent = String(photos.length);
renderFileList();
updateProgress();
if (photos.length) {
elements.viewerEmpty.classList.add('hidden');
elements.viewerContent.classList.remove('hidden');
await selectPhoto(0);
} else {
elements.viewerContent.classList.add('hidden');
elements.viewerEmpty.classList.remove('hidden');
showToast('No JPG or JPEG files were found in that folder.', true);
}
}
function clearObjectUrls() {
if (state.objectUrl) URL.revokeObjectURL(state.objectUrl);
state.objectUrl = null;
for (const url of state.thumbUrls.values()) URL.revokeObjectURL(url);
state.thumbUrls.clear();
}
async function getThumbUrl(photo) {
if (state.thumbUrls.has(photo.name)) return state.thumbUrls.get(photo.name);
const file = await photo.handle.getFile();
const url = URL.createObjectURL(file);
state.thumbUrls.set(photo.name, url);
return url;
}
function renderFileList() {
const filter = elements.filter.value.trim().toLowerCase();
const matching = state.photos
.map((photo, index) => ({ photo, index }))
.filter(({ photo }) => !filter || photo.name.toLowerCase().includes(filter));
elements.fileList.innerHTML = '';
if (!matching.length) {
elements.fileList.innerHTML = '<div class="empty-list">No matching photos.</div>';
return;
}
for (const { photo, index } of matching) {
const row = document.createElement('button');
row.type = 'button';
row.className = `file-row${index === state.currentIndex ? ' active' : ''}`;
row.dataset.index = String(index);
const symbol = photo.status === 'saved' ? '✓' : photo.status === 'failed' ? '!' : '';
row.innerHTML = `
<div class="file-thumb" aria-hidden="true"></div>
<div class="file-meta"><strong>${escapeHtml(photo.name)}</strong><span>${index + 1} / ${state.photos.length} · ${formatBytes(photo.size)}</span></div>
<span class="file-state ${photo.status}" title="${photo.status}">${symbol}</span>`;
row.addEventListener('click', () => selectPhoto(index));
elements.fileList.appendChild(row);
getThumbUrl(photo).then((url) => {
const placeholder = row.querySelector('.file-thumb');
const image = document.createElement('img');
image.className = 'file-thumb';
image.alt = '';
image.src = url;
placeholder.replaceWith(image);
}).catch(() => {});
}
}
async function selectPhoto(index, force = false) {
if (index < 0 || index >= state.photos.length || (state.processing && !force)) return;
captureCurrentValues();
state.currentIndex = index;
const photo = state.photos[index];
const file = await photo.handle.getFile();
if (state.objectUrl) URL.revokeObjectURL(state.objectUrl);
state.objectUrl = URL.createObjectURL(file);
elements.preview.src = state.objectUrl;
elements.filename.textContent = photo.name;
elements.filesize.textContent = `${formatBytes(file.size)} · modified ${new Date(file.lastModified).toLocaleString()}`;
elements.position.textContent = `${index + 1} / ${state.photos.length}`;
resetView();
restoreValues(photo.values);
setSaveStatus(
photo.status === 'saved' ? 'saved' : photo.status === 'failed' ? 'failed' : 'idle',
photo.status === 'saved' ? 'Previously saved' : photo.status === 'failed' ? 'Previous save failed' : 'Ready to edit',
photo.name,
);
updateNavigation();
renderFileList();
const active = elements.fileList.querySelector('.file-row.active');
active?.scrollIntoView({ block: 'nearest' });
}
function updateNavigation() {
const hasPhoto = state.currentIndex >= 0;
elements.previous.disabled = !hasPhoto || state.currentIndex === 0 || state.processing;
elements.skip.disabled = !hasPhoto || state.processing;
elements.save.disabled = !hasPhoto || state.processing;
elements.saveNext.disabled = !hasPhoto || state.processing;
elements.copyPrevious.disabled = !hasPhoto || state.currentIndex === 0 || state.processing;
}
function currentPrecision() {
return $('input[name="precision"]:checked').value;
}
function updatePrecisionFields() {
const precision = currentPrecision();
elements.month.disabled = precision === 'year' || precision === 'approximate';
elements.day.disabled = precision !== 'exact';
}
function formValues() {
return {
precision: currentPrecision(),
year: elements.year.value,
month: elements.month.value,
day: elements.day.value,
timeMode: elements.timeMode.value,
time: elements.time.value,
description: elements.description.value,
keywords: elements.keywords.value,
};
}
function captureCurrentValues() {
if (state.currentIndex < 0 || !state.photos[state.currentIndex]) return;
state.photos[state.currentIndex].values = formValues();
}
function restoreValues(values) {
const defaults = {
precision: 'exact', year: '', month: '1', day: '1', timeMode: 'unknown',
time: '12:00:00', description: '', keywords: '',
};
const value = { ...defaults, ...(values || {}) };
const radio = $(`input[name="precision"][value="${CSS.escape(value.precision)}"]`);
if (radio) radio.checked = true;
elements.year.value = value.year;
elements.month.value = value.month;
elements.day.value = value.day;
elements.timeMode.value = value.timeMode;
elements.time.value = value.time;
elements.description.value = value.description;
elements.keywords.value = value.keywords;
elements.timeField.classList.toggle('hidden', value.timeMode !== 'known');
elements.descriptionCount.textContent = `${elements.description.value.length} / 2000`;
updatePrecisionFields();
}
function parseKeywords(value) {
return [...new Set(value.split(/[\n,]+/).map((item) => item.trim()).filter(Boolean))];
}
function validateForm() {
if (!elements.year.value) throw new Error('Enter a year before saving.');
const year = Number(elements.year.value);
if (!Number.isInteger(year) || year < 1800 || year > 2200) throw new Error('Year must be between 1800 and 2200.');
if (currentPrecision() === 'exact' && !elements.day.value) throw new Error('Enter a day for an exact date.');
}
async function savePhoto(moveNext) {
if (state.processing || state.currentIndex < 0) return;
try { validateForm(); }
catch (error) { showToast(error.message, true); return; }
const photo = state.photos[state.currentIndex];
state.processing = true;
updateNavigation();
setSaveStatus('saving', 'Saving metadata…', photo.name);
try {
let permission = await photo.handle.queryPermission({ mode: 'readwrite' });
if (permission !== 'granted') permission = await photo.handle.requestPermission({ mode: 'readwrite' });
if (permission !== 'granted') throw new Error('Write permission was not granted.');
const file = await photo.handle.getFile();
const values = formValues();
const payload = new FormData();
payload.append('file', file, photo.name);
payload.append('precision', values.precision);
payload.append('year', values.year);
if (values.precision === 'exact' || values.precision === 'month') payload.append('month', values.month);
if (values.precision === 'exact') payload.append('day', values.day);
if (values.timeMode === 'known') payload.append('time_value', values.time);
payload.append('description', values.description);
payload.append('keywords_json', JSON.stringify(parseKeywords(values.keywords)));
const response = await fetch('/api/process', { method: 'POST', body: payload });
if (!response.ok) {
let message = `Save failed (${response.status}).`;
try { message = (await response.json()).detail || message; } catch {}
throw new Error(message);
}
const updatedBlob = await response.blob();
const writable = await photo.handle.createWritable({ keepExistingData: false });
try {
await writable.write(updatedBlob);
await writable.close();
} catch (error) {
await writable.abort().catch(() => {});
throw error;
}
const updatedFile = await photo.handle.getFile();
photo.size = updatedFile.size;
photo.lastModified = updatedFile.lastModified;
photo.status = 'saved';
photo.values = values;
saveFolderState();
setSaveStatus('saved', 'Saved in place', `${photo.name} was overwritten successfully.`);
showToast(`Saved ${photo.name}`);
updateProgress();
const oldThumb = state.thumbUrls.get(photo.name);
if (oldThumb) URL.revokeObjectURL(oldThumb);
state.thumbUrls.delete(photo.name);
if (moveNext && state.currentIndex < state.photos.length - 1) {
await selectPhoto(state.currentIndex + 1, true);
} else {
await selectPhoto(state.currentIndex, true);
}
} catch (error) {
photo.status = 'failed';
saveFolderState();
setSaveStatus('failed', 'Save failed', error.message || 'The original file was not changed.');
showToast(error.message || 'Save failed.', true);
renderFileList();
updateProgress();
} finally {
state.processing = false;
updateNavigation();
}
}
function updateProgress() {
const saved = state.photos.filter((photo) => photo.status === 'saved').length;
const total = state.photos.length;
elements.progressCount.textContent = `${saved} / ${total}`;
elements.progressBar.style.width = total ? `${(saved / total) * 100}%` : '0%';
}
function goRelative(delta) {
const target = state.currentIndex + delta;
if (target >= 0 && target < state.photos.length) selectPhoto(target);
}
function copyPreviousValues() {
if (state.currentIndex <= 0) return;
captureCurrentValues();
const previous = state.photos[state.currentIndex - 1].values;
if (!previous) return showToast('The previous photo has no entered values yet.', true);
restoreValues({ ...previous });
captureCurrentValues();
showToast('Copied values from the previous photo.');
}
function applyView() {
elements.preview.style.transform = `scale(${state.zoom}) rotate(${state.rotation}deg)`;
}
function resetView() { state.zoom = 1; state.rotation = 0; applyView(); }
for (const button of elements.openButtons) button.addEventListener('click', openFolder);
elements.rescan.addEventListener('click', scanFolder);
elements.filter.addEventListener('input', renderFileList);
elements.previous.addEventListener('click', () => goRelative(-1));
elements.skip.addEventListener('click', () => goRelative(1));
elements.save.addEventListener('click', () => savePhoto(false));
elements.saveNext.addEventListener('click', () => savePhoto(true));
elements.copyPrevious.addEventListener('click', copyPreviousValues);
elements.zoomOut.addEventListener('click', () => { state.zoom = Math.max(.25, state.zoom - .15); applyView(); });
elements.zoomIn.addEventListener('click', () => { state.zoom = Math.min(4, state.zoom + .15); applyView(); });
elements.fit.addEventListener('click', resetView);
elements.rotateLeft.addEventListener('click', () => { state.rotation -= 90; applyView(); });
elements.rotateRight.addEventListener('click', () => { state.rotation += 90; applyView(); });
elements.resetView.addEventListener('click', resetView);
elements.timeMode.addEventListener('change', () => elements.timeField.classList.toggle('hidden', elements.timeMode.value !== 'known'));
elements.description.addEventListener('input', () => { elements.descriptionCount.textContent = `${elements.description.value.length} / 2000`; captureCurrentValues(); });
elements.form.addEventListener('input', captureCurrentValues);
for (const radio of $$('input[name="precision"]')) radio.addEventListener('change', updatePrecisionFields);
for (const tab of $$('.tab')) {
tab.addEventListener('click', () => {
$$('.tab').forEach((item) => item.classList.toggle('active', item === tab));
$$('.tab-panel').forEach((panel) => panel.classList.toggle('active', panel.dataset.panel === tab.dataset.tab));
});
}
elements.shortcutsButton.addEventListener('click', () => elements.shortcutsDialog.showModal());
elements.closeShortcuts.addEventListener('click', () => elements.shortcutsDialog.close());
window.addEventListener('keydown', (event) => {
if (elements.shortcutsDialog.open) return;
const tag = document.activeElement?.tagName;
const typing = ['INPUT', 'TEXTAREA', 'SELECT'].includes(tag);
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault(); savePhoto(false); return;
}
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
event.preventDefault(); savePhoto(true); return;
}
if (!typing && event.key === 'Enter') { event.preventDefault(); savePhoto(true); }
else if (!typing && event.key === 'ArrowLeft') goRelative(-1);
else if (!typing && event.key === 'ArrowRight') goRelative(1);
else if (!typing && event.key.toLowerCase() === 's') goRelative(1);
else if (!typing && event.key.toLowerCase() === 'c') copyPreviousValues();
});
async function initialise() {
try {
const response = await fetch('/api/config', { cache: 'no-store' });
if (response.ok) {
const config = await response.json();
elements.appName.textContent = config.appName;
document.title = config.appName;
}
} catch {}
if (!isSupported()) {
setSaveStatus('failed', 'Browser folder access unavailable', 'Use current Chrome or Edge over HTTPS.');
for (const button of elements.openButtons) button.title = 'Requires Chrome/Edge and HTTPS';
}
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/service-worker.js').catch(() => {});
}
initialise();
+205
View File
@@ -0,0 +1,205 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#121820">
<title>Photo Date Editor</title>
<link rel="manifest" href="/manifest.webmanifest">
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div class="app-shell">
<header class="topbar">
<div class="brand">
<div class="brand-mark" aria-hidden="true">◫</div>
<div>
<h1 id="app-name">Photo Date Editor</h1>
<p>Local folder · in-place JPEG metadata</p>
</div>
</div>
<div class="top-actions">
<button id="shortcuts-button" class="button ghost">⌨ Keyboard shortcuts</button>
<button id="open-folder-top" class="button primary">Open photo folder</button>
</div>
</header>
<main class="workspace">
<aside class="sidebar left-panel">
<section class="panel-section folder-section">
<div class="section-label">Folder</div>
<div class="folder-row">
<div>
<strong id="folder-name">No folder selected</strong>
<span id="folder-access">Choose a local Chromebook folder</span>
</div>
<button id="open-folder" class="button compact">Change</button>
</div>
</section>
<section class="panel-section progress-section">
<div class="section-heading">
<span>Progress</span>
<span id="progress-count">0 / 0</span>
</div>
<div class="progress-track"><div id="progress-bar" class="progress-bar"></div></div>
<div class="status-legend">
<span><i class="dot saved"></i>Saved</span>
<span><i class="dot pending"></i>Pending</span>
<span><i class="dot failed"></i>Failed</span>
</div>
</section>
<section class="panel-section file-section">
<div class="section-heading"><span>Photos</span><span id="photo-count">0</span></div>
<input id="filter-input" class="input filter" type="search" placeholder="Filter filenames…" disabled>
<div id="file-list" class="file-list" aria-live="polite">
<div class="empty-list">Choose a folder containing JPG or JPEG photos.</div>
</div>
</section>
<section class="sidebar-footer">
<button id="rescan-folder" class="button wide" disabled>↻ Rescan folder</button>
</section>
</aside>
<section class="viewer-panel">
<div id="viewer-empty" class="viewer-empty">
<div class="empty-icon">▧</div>
<h2>Choose your scanned-photo folder</h2>
<p>The site will request read/write access. Photos stay on this device.</p>
<button id="open-folder-center" class="button primary large">Open photo folder</button>
</div>
<div id="viewer-content" class="viewer-content hidden">
<div class="image-stage">
<img id="photo-preview" alt="Current scanned photograph">
</div>
<div class="viewer-tools">
<div class="tool-group">
<button id="zoom-out" class="icon-button" title="Zoom out">−</button>
<button id="zoom-in" class="icon-button" title="Zoom in">+</button>
<button id="fit-image" class="button compact">Fit</button>
</div>
<div class="filename-block">
<strong id="current-filename">—</strong>
<span id="current-filesize">—</span>
</div>
<div class="tool-group">
<button id="rotate-left" class="icon-button" title="Rotate preview left">↶</button>
<button id="rotate-right" class="icon-button" title="Rotate preview right">↷</button>
<button id="reset-view" class="icon-button" title="Reset preview">⟳</button>
</div>
</div>
<div class="navigation-bar">
<button id="previous-button" class="button">← Previous</button>
<div id="viewer-position" class="position">0 / 0</div>
<div class="navigation-actions">
<button id="skip-button" class="button ghost">Skip</button>
<button id="save-button" class="button">Save</button>
<button id="save-next-button" class="button primary">Save &amp; Next →</button>
</div>
</div>
</div>
</section>
<aside class="sidebar right-panel">
<div class="tabs" role="tablist">
<button class="tab active" data-tab="date" role="tab">Date &amp; time</button>
<button class="tab" data-tab="details" role="tab">Details</button>
<button class="tab" data-tab="keywords" role="tab">Keywords</button>
</div>
<form id="metadata-form" autocomplete="off">
<section class="tab-panel active" data-panel="date">
<fieldset>
<legend>Date precision</legend>
<label class="radio-row"><input type="radio" name="precision" value="exact" checked> <span>Exact date</span></label>
<label class="radio-row"><input type="radio" name="precision" value="month"> <span>Month and year</span></label>
<label class="radio-row"><input type="radio" name="precision" value="year"> <span>Year only</span></label>
<label class="radio-row"><input type="radio" name="precision" value="approximate"> <span>Approximate year</span></label>
</fieldset>
<div class="field-grid date-grid">
<label class="field">
<span>Day</span>
<input id="day-input" class="input" type="number" min="1" max="31" value="1">
</label>
<label class="field month-field">
<span>Month</span>
<select id="month-input" class="input">
<option value="1">January</option><option value="2">February</option>
<option value="3">March</option><option value="4">April</option>
<option value="5">May</option><option value="6">June</option>
<option value="7">July</option><option value="8">August</option>
<option value="9">September</option><option value="10">October</option>
<option value="11">November</option><option value="12">December</option>
</select>
</label>
<label class="field">
<span>Year</span>
<input id="year-input" class="input" type="number" min="1800" max="2200" placeholder="1987" required>
</label>
</div>
<label class="field">
<span>Time</span>
<select id="time-mode" class="input">
<option value="unknown">Unknown — use 12:00:00</option>
<option value="known">Enter a time</option>
</select>
</label>
<label id="time-field" class="field hidden">
<span>Time value</span>
<input id="time-input" class="input" type="time" step="1" value="12:00:00">
</label>
<div class="info-card">
<strong>Partial dates</strong>
<p>EXIF requires a full timestamp. Month/year uses day 1; year-only uses January 1. XMP also stores the reduced precision.</p>
</div>
</section>
<section class="tab-panel" data-panel="details">
<label class="field">
<span>Description</span>
<textarea id="description-input" class="input textarea" rows="7" maxlength="2000" placeholder="Who, where, occasion, or other context…"></textarea>
</label>
<div class="helper-row"><span>Written to EXIF, XMP and IPTC</span><span id="description-count">0 / 2000</span></div>
</section>
<section class="tab-panel" data-panel="keywords">
<label class="field">
<span>People and keywords</span>
<textarea id="keywords-input" class="input textarea" rows="7" placeholder="Daniel, family, birthday, Bergen"></textarea>
</label>
<p class="field-help">Separate entries with commas or new lines. Written as XMP Subject and IPTC Keywords.</p>
</section>
</form>
<section class="right-footer">
<div id="save-status" class="save-status idle">
<span class="status-icon">●</span>
<div><strong>No photo selected</strong><span>Open a folder to begin.</span></div>
</div>
<button id="copy-previous" class="button wide" disabled>Copy previous values</button>
</section>
</aside>
</main>
</div>
<dialog id="shortcuts-dialog">
<div class="dialog-heading"><h2>Keyboard shortcuts</h2><button id="close-shortcuts" class="icon-button">×</button></div>
<dl class="shortcut-list">
<div><dt>Enter / Ctrl+Enter</dt><dd>Save and open next photo</dd></div>
<div><dt>Ctrl+S</dt><dd>Save without moving</dd></div>
<div><dt>← / →</dt><dd>Previous or next photo</dd></div>
<div><dt>S</dt><dd>Skip to next photo</dd></div>
<div><dt>C</dt><dd>Copy previous photo's values</dd></div>
</dl>
</dialog>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="/app.js" type="module"></script>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
{
"name": "Photo Date Editor",
"short_name": "Photo Dates",
"start_url": "/",
"display": "standalone",
"background_color": "#0d1218",
"theme_color": "#121820",
"description": "Edit dates, descriptions, and keywords in scanned JPEG photos."
}
+25
View File
@@ -0,0 +1,25 @@
const CACHE_NAME = 'photo-date-editor-v1';
const SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest'];
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL)));
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))))
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET' || new URL(event.request.url).pathname.startsWith('/api/')) return;
event.respondWith(
fetch(event.request).then((response) => {
const copy = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, copy));
return response;
}).catch(() => caches.match(event.request))
);
});
+107
View File
@@ -0,0 +1,107 @@
:root {
color-scheme: dark;
--bg: #0d1218;
--panel: #131a22;
--panel-2: #18212b;
--panel-3: #0f151c;
--line: #2a3542;
--line-soft: #202a35;
--text: #eef3f8;
--muted: #9aa8b7;
--primary: #4f87ff;
--primary-hover: #6a99ff;
--primary-soft: rgba(79, 135, 255, 0.18);
--success: #54c98a;
--warning: #e4b35c;
--danger: #ed6a72;
--shadow: 0 14px 40px rgba(0, 0, 0, 0.28);
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--text); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
button, input, select, textarea { font: inherit; }
button { color: inherit; }
.app-shell { min-height: 100vh; display: flex; flex-direction: column; }
.topbar { height: 70px; display: flex; align-items: center; justify-content: space-between; padding: 0 20px; border-bottom: 1px solid var(--line); background: linear-gradient(180deg, #151c24, #111820); }
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark { width: 34px; height: 34px; border: 1px solid #5d8df6; color: #8aafff; border-radius: 7px; display: grid; place-items: center; font-weight: 700; }
.brand h1 { margin: 0; font-size: 20px; }
.brand p { margin: 3px 0 0; color: var(--muted); font-size: 12px; }
.top-actions { display: flex; gap: 10px; }
.workspace { flex: 1; min-height: calc(100vh - 70px); display: grid; grid-template-columns: minmax(260px, 320px) minmax(480px, 1fr) minmax(330px, 420px); overflow: hidden; }
.sidebar { background: var(--panel); min-height: 0; display: flex; flex-direction: column; }
.left-panel { border-right: 1px solid var(--line); }
.right-panel { border-left: 1px solid var(--line); }
.panel-section { padding: 18px; border-bottom: 1px solid var(--line-soft); }
.section-label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; margin-bottom: 10px; }
.section-heading { display: flex; justify-content: space-between; align-items: center; font-weight: 650; margin-bottom: 12px; }
.folder-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.folder-row strong, .folder-row span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.folder-row strong { max-width: 185px; }
.folder-row span { color: var(--muted); font-size: 12px; margin-top: 4px; max-width: 185px; }
.progress-track { height: 7px; background: #2a3440; border-radius: 99px; overflow: hidden; }
.progress-bar { width: 0; height: 100%; background: var(--primary); transition: width .2s ease; }
.status-legend { display: flex; gap: 12px; margin-top: 11px; color: var(--muted); font-size: 11px; }
.dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 5px; }
.dot.saved { background: var(--success); }.dot.pending { background: #748294; }.dot.failed { background: var(--danger); }
.file-section { flex: 1; display: flex; min-height: 0; flex-direction: column; }
.file-list { flex: 1; min-height: 120px; overflow: auto; margin-top: 10px; padding-right: 3px; }
.empty-list { color: var(--muted); font-size: 13px; line-height: 1.55; padding: 15px 5px; }
.file-row { width: 100%; border: 0; border-radius: 8px; background: transparent; display: grid; grid-template-columns: 50px minmax(0, 1fr) 22px; align-items: center; gap: 10px; padding: 8px; text-align: left; cursor: pointer; }
.file-row:hover { background: rgba(255,255,255,.045); }
.file-row.active { background: var(--primary-soft); outline: 1px solid rgba(79,135,255,.4); }
.file-thumb { width: 50px; height: 44px; object-fit: cover; background: #0b1015; border-radius: 5px; }
.file-meta { min-width: 0; }
.file-meta strong, .file-meta span { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.file-meta strong { font-size: 13px; }.file-meta span { margin-top: 4px; color: var(--muted); font-size: 11px; }
.file-state { width: 19px; height: 19px; display: grid; place-items: center; border-radius: 50%; font-size: 11px; border: 1px solid #536071; color: #8390a0; }
.file-state.saved { color: #092b1b; background: var(--success); border-color: var(--success); font-weight: 800; }
.file-state.failed { color: white; background: var(--danger); border-color: var(--danger); }
.sidebar-footer, .right-footer { padding: 16px 18px; border-top: 1px solid var(--line-soft); margin-top: auto; }
.viewer-panel { min-width: 0; min-height: 0; display: flex; background: #0a0f14; }
.viewer-empty { margin: auto; text-align: center; max-width: 470px; padding: 35px; }
.empty-icon { font-size: 48px; color: #6685ae; }.viewer-empty h2 { margin: 15px 0 7px; }.viewer-empty p { color: var(--muted); line-height: 1.6; margin: 0 0 22px; }
.viewer-content { width: 100%; min-height: 0; display: grid; grid-template-rows: minmax(300px, 1fr) auto 76px; }
.image-stage { min-height: 0; display: grid; place-items: center; overflow: auto; padding: 18px; background-image: radial-gradient(circle at 50% 40%, #18212a 0, #0a0f14 68%); }
.image-stage img { display: block; max-width: 100%; max-height: 100%; object-fit: contain; box-shadow: var(--shadow); transition: transform .15s ease; transform-origin: center; }
.viewer-tools { border-top: 1px solid var(--line-soft); min-height: 58px; display: grid; grid-template-columns: 1fr minmax(170px, auto) 1fr; align-items: center; padding: 10px 18px; gap: 18px; background: #10171f; }
.viewer-tools > :last-child { justify-self: end; }
.tool-group { display: flex; align-items: center; gap: 7px; }
.filename-block { text-align: center; min-width: 0; }.filename-block strong, .filename-block span { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }.filename-block span { color: var(--muted); font-size: 11px; margin-top: 3px; }
.navigation-bar { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 16px; padding: 15px 18px; border-top: 1px solid var(--line); background: #111820; }
.navigation-actions { display: flex; gap: 8px; justify-self: end; }.position { font-size: 18px; color: #c8d1da; min-width: 90px; text-align: center; }
.tabs { display: grid; grid-template-columns: repeat(3, 1fr); border-bottom: 1px solid var(--line); }
.tab { border: 0; border-bottom: 3px solid transparent; background: transparent; padding: 18px 8px 14px; cursor: pointer; color: var(--muted); }
.tab:hover { color: var(--text); }.tab.active { color: var(--text); border-bottom-color: var(--primary); }
#metadata-form { flex: 1; min-height: 0; overflow: auto; padding: 20px; }
.tab-panel { display: none; }.tab-panel.active { display: block; }
fieldset { border: 0; padding: 0; margin: 0 0 22px; } legend { font-weight: 650; margin-bottom: 10px; }
.radio-row { display: flex; align-items: center; gap: 8px; padding: 6px 0; cursor: pointer; }.radio-row input { accent-color: var(--primary); }
.field { display: block; margin-bottom: 17px; }.field > span { display: block; font-size: 12px; font-weight: 650; margin-bottom: 7px; color: #cbd4dd; }
.field-grid { display: grid; gap: 10px; }.date-grid { grid-template-columns: 78px 1fr 105px; }
.input { width: 100%; border: 1px solid #384555; border-radius: 7px; background: #10171f; color: var(--text); padding: 10px 11px; outline: none; }
.input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(79,135,255,.13); }.input:disabled { opacity: .45; cursor: not-allowed; }.filter { padding: 9px 10px; }
.textarea { resize: vertical; min-height: 125px; line-height: 1.55; }
.field-help, .helper-row { color: var(--muted); font-size: 11px; line-height: 1.5; }.helper-row { display: flex; justify-content: space-between; margin-top: -9px; }
.info-card { border: 1px solid #4c4434; background: #211d16; padding: 12px; border-radius: 8px; color: #d7c8a9; font-size: 12px; line-height: 1.5; }.info-card strong { color: #ead4a6; }.info-card p { margin: 4px 0 0; }
.save-status { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }.save-status .status-icon { color: #748294; }.save-status strong, .save-status span { display: block; }.save-status strong { font-size: 13px; }.save-status span { color: var(--muted); font-size: 11px; margin-top: 3px; }.save-status.saving .status-icon { color: var(--warning); }.save-status.saved .status-icon { color: var(--success); }.save-status.failed .status-icon { color: var(--danger); }
.button, .icon-button { border: 1px solid #354150; background: #1a232d; border-radius: 7px; cursor: pointer; transition: .15s ease; }
.button { padding: 10px 14px; }.button:hover, .icon-button:hover { border-color: #566578; background: #222d39; }.button:disabled, .icon-button:disabled { opacity: .45; cursor: not-allowed; }.button.primary { border-color: #4f87ff; background: #376fdf; }.button.primary:hover { background: #477feb; }.button.ghost { background: transparent; }.button.compact { padding: 8px 11px; }.button.large { padding: 12px 18px; }.button.wide { width: 100%; }.icon-button { width: 38px; height: 38px; display: grid; place-items: center; font-size: 18px; }
.hidden { display: none !important; }
#shortcuts-dialog { width: min(520px, calc(100vw - 30px)); background: var(--panel-2); color: var(--text); border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow); }
#shortcuts-dialog::backdrop { background: rgba(0,0,0,.65); }.dialog-heading { display: flex; justify-content: space-between; align-items: center; }.dialog-heading h2 { margin: 0; }.shortcut-list > div { display: grid; grid-template-columns: 180px 1fr; padding: 11px 0; border-bottom: 1px solid var(--line-soft); }.shortcut-list dt { font-weight: 700; }.shortcut-list dd { margin: 0; color: var(--muted); }
.toast { position: fixed; left: 50%; bottom: 25px; transform: translate(-50%, 25px); opacity: 0; pointer-events: none; padding: 11px 16px; border: 1px solid var(--line); border-radius: 8px; background: #1c2631; box-shadow: var(--shadow); transition: .2s ease; z-index: 30; }.toast.show { opacity: 1; transform: translate(-50%, 0); }.toast.error { border-color: var(--danger); }
@media (max-width: 1100px) {
.workspace { grid-template-columns: 250px minmax(380px, 1fr) 340px; }
}
@media (max-width: 850px) {
.topbar { height: auto; min-height: 70px; flex-wrap: wrap; gap: 10px; padding: 12px; }.brand p { display: none; }
.workspace { display: block; overflow: visible; }.left-panel, .right-panel { border: 0; }.left-panel { max-height: 480px; }.viewer-panel { min-height: 620px; }.right-panel { min-height: 600px; }
}