Initial JPEG photo metadata editor v0.1
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# URL used by your browser through Caddy.
|
||||
# The application itself uses relative URLs, so this is informational and shown in /api/config.
|
||||
APP_URL=https://photos.example.internal
|
||||
|
||||
# Host port exposed by Docker Compose. Caddy can reverse_proxy to this port.
|
||||
APP_PORT=8080
|
||||
|
||||
# Optional settings
|
||||
APP_NAME=Photo Date Editor
|
||||
MAX_UPLOAD_MB=150
|
||||
LOG_LEVEL=INFO
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
.env
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libimage-exiftool-perl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app/ ./
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080", "--proxy-headers", "--forwarded-allow-ips=*"]
|
||||
@@ -0,0 +1,111 @@
|
||||
# Photo Date Editor
|
||||
|
||||
A self-hosted browser UI for manually dating scanned photographs. The browser receives temporary read/write access to a local Chromebook or computer folder, sends one JPEG at a time to the Docker backend for ExifTool processing, and overwrites the same local file after processing.
|
||||
|
||||
## Current scope
|
||||
|
||||
- JPG and JPEG
|
||||
- Local directory picker in Chrome/Edge
|
||||
- Three-column photo workflow
|
||||
- Exact date, month/year, year-only, approximate year
|
||||
- Optional time; unknown time defaults to noon
|
||||
- Description and keywords
|
||||
- In-place overwrite: no browser download and no `_original` file
|
||||
- Saved/pending/failed state stored in browser local storage
|
||||
- Keyboard navigation
|
||||
|
||||
## Requirements
|
||||
|
||||
- Docker with Docker Compose
|
||||
- A current Chromium browser such as Chrome or Edge
|
||||
- HTTPS when accessed from another device
|
||||
- A backup of irreplaceable scans before testing any metadata editor
|
||||
|
||||
The browser File System Access API is required. Firefox and Safari are not supported by this first version.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Example `.env`:
|
||||
|
||||
```dotenv
|
||||
APP_URL=https://photos.example.internal
|
||||
APP_PORT=8080
|
||||
APP_NAME=Photo Date Editor
|
||||
MAX_UPLOAD_MB=150
|
||||
LOG_LEVEL=INFO
|
||||
```
|
||||
|
||||
Open the configured HTTPS URL through Caddy, click **Open photo folder**, and grant read/write access.
|
||||
|
||||
## Caddy example
|
||||
|
||||
Caddy may run in another container or host. Proxy to the Docker host and exposed port:
|
||||
|
||||
```caddyfile
|
||||
photos.example.internal {
|
||||
@lan remote_ip private_ranges
|
||||
|
||||
handle @lan {
|
||||
reverse_proxy 192.168.1.20:8080
|
||||
}
|
||||
|
||||
respond 403
|
||||
}
|
||||
```
|
||||
|
||||
The browser directory picker requires a secure context. Use a certificate trusted by the Chromebook. A real domain with internal DNS is usually easier than deploying a private CA certificate to every family device.
|
||||
|
||||
## What happens when Save is clicked
|
||||
|
||||
1. The browser reads the selected local JPEG.
|
||||
2. It sends the file and entered fields to `/api/process`.
|
||||
3. ExifTool modifies a temporary file inside the container using `-overwrite_original`.
|
||||
4. The backend returns the modified JPEG.
|
||||
5. Chrome writes the returned bytes over the selected original file with `createWritable()`.
|
||||
6. The backend temporary directory is deleted automatically.
|
||||
|
||||
No second photo is intentionally left on the Chromebook or Docker host.
|
||||
|
||||
## Date behavior
|
||||
|
||||
EXIF date fields require a complete timestamp:
|
||||
|
||||
| Selected precision | EXIF value | XMP DateCreated |
|
||||
|---|---|---|
|
||||
| Exact date | Chosen date | `YYYY-MM-DD` |
|
||||
| Month and year | First day of month | `YYYY-MM` |
|
||||
| Year only | January 1 at noon | `YYYY` |
|
||||
| Approximate year | January 1 at noon | `YYYY` |
|
||||
|
||||
The chosen precision is also written into XMP Photoshop Instructions.
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
- `Enter` or `Ctrl+Enter`: Save & Next
|
||||
- `Ctrl+S`: Save without moving
|
||||
- `Left` / `Right`: Previous / next photo when not typing
|
||||
- `S`: Skip when not typing
|
||||
- `C`: Copy previous values when not typing
|
||||
|
||||
## Important limitations
|
||||
|
||||
- The app cannot silently access a Chromebook folder. The user must choose it and approve read/write access.
|
||||
- Browser permission may need to be granted again after closing/reopening the browser.
|
||||
- Overwriting a file through the browser generally changes its filesystem modified time to the time of the save. EXIF/XMP photo dates are independent of that filesystem timestamp.
|
||||
- Preview rotation is visual only in this MVP; it does not rotate image pixels or write orientation metadata.
|
||||
- Test with copies first, then use your own normal backup routine for the originals.
|
||||
|
||||
## Planned format expansion
|
||||
|
||||
The backend already uses ExifTool, which is a good foundation for later format support. The next likely steps are:
|
||||
|
||||
1. HEIC/HEIF preview and metadata round-trip testing on ChromeOS.
|
||||
2. Canon RAW (`CR2`, `CR3`) and Nikon RAW (`NEF`, `NRW`).
|
||||
3. Sidecar XMP mode for RAW files, because modifying proprietary RAW containers directly is a different risk profile from JPEG.
|
||||
4. Optional server-side folder mode for photos stored on NAS-mounted storage.
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
APP_NAME = os.getenv("APP_NAME", "Photo Date Editor")
|
||||
APP_URL = os.getenv("APP_URL", "http://localhost:8080")
|
||||
MAX_UPLOAD_MB = int(os.getenv("MAX_UPLOAD_MB", "150"))
|
||||
MAX_UPLOAD_BYTES = MAX_UPLOAD_MB * 1024 * 1024
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, LOG_LEVEL, logging.INFO),
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("photo-date-editor")
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
STATIC_DIR = BASE_DIR / "static"
|
||||
|
||||
app = FastAPI(title=APP_NAME, docs_url=None, redoc_url=None)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/api/config")
|
||||
def config() -> dict[str, str | int]:
|
||||
return {
|
||||
"appName": APP_NAME,
|
||||
"appUrl": APP_URL,
|
||||
"maxUploadMb": MAX_UPLOAD_MB,
|
||||
}
|
||||
|
||||
|
||||
def _normalise_datetime(
|
||||
precision: str,
|
||||
year: int,
|
||||
month: int | None,
|
||||
day: int | None,
|
||||
time_value: str | None,
|
||||
) -> tuple[str, str, str]:
|
||||
"""Return EXIF datetime, XMP reduced-precision date, and a readable precision label."""
|
||||
if not 1800 <= year <= 2200:
|
||||
raise HTTPException(status_code=422, detail="Year must be between 1800 and 2200.")
|
||||
|
||||
precision_labels = {
|
||||
"exact": "Exact date",
|
||||
"month": "Month and year",
|
||||
"year": "Year only",
|
||||
"approximate": "Approximate year",
|
||||
}
|
||||
if precision not in precision_labels:
|
||||
raise HTTPException(status_code=422, detail="Unsupported date precision.")
|
||||
|
||||
if precision == "exact":
|
||||
if month is None or day is None:
|
||||
raise HTTPException(status_code=422, detail="Exact dates require month and day.")
|
||||
effective_month = month
|
||||
effective_day = day
|
||||
xmp_date = f"{year:04d}-{month:02d}-{day:02d}"
|
||||
elif precision == "month":
|
||||
if month is None:
|
||||
raise HTTPException(status_code=422, detail="Month-and-year dates require a month.")
|
||||
effective_month = month
|
||||
effective_day = 1
|
||||
xmp_date = f"{year:04d}-{month:02d}"
|
||||
else:
|
||||
effective_month = 1
|
||||
effective_day = 1
|
||||
xmp_date = f"{year:04d}"
|
||||
|
||||
effective_time = time_value.strip() if time_value else "12:00:00"
|
||||
try:
|
||||
parsed = datetime.strptime(
|
||||
f"{year:04d}-{effective_month:02d}-{effective_day:02d} {effective_time}",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=f"Invalid date or time: {exc}") from exc
|
||||
|
||||
exif_datetime = parsed.strftime("%Y:%m:%d %H:%M:%S")
|
||||
return exif_datetime, xmp_date, precision_labels[precision]
|
||||
|
||||
|
||||
async def _save_upload(upload: UploadFile, destination: Path) -> None:
|
||||
total = 0
|
||||
with destination.open("wb") as output:
|
||||
while chunk := await upload.read(1024 * 1024):
|
||||
total += len(chunk)
|
||||
if total > MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"Image is larger than the configured {MAX_UPLOAD_MB} MB limit.",
|
||||
)
|
||||
output.write(chunk)
|
||||
|
||||
with destination.open("rb") as source:
|
||||
signature = source.read(3)
|
||||
if signature[:2] != b"\xff\xd8":
|
||||
raise HTTPException(status_code=415, detail="The uploaded file is not a valid JPEG image.")
|
||||
|
||||
|
||||
@app.post("/api/process")
|
||||
async def process_photo(
|
||||
file: Annotated[UploadFile, File(...)],
|
||||
precision: Annotated[str, Form(...)],
|
||||
year: Annotated[int, Form(...)],
|
||||
month: Annotated[int | None, Form()] = None,
|
||||
day: Annotated[int | None, Form()] = None,
|
||||
time_value: Annotated[str | None, Form()] = None,
|
||||
description: Annotated[str, Form()] = "",
|
||||
keywords_json: Annotated[str, Form()] = "[]",
|
||||
) -> Response:
|
||||
filename = file.filename or "photo.jpg"
|
||||
suffix = Path(filename).suffix.lower()
|
||||
if suffix not in {".jpg", ".jpeg"}:
|
||||
raise HTTPException(status_code=415, detail="This version supports JPG and JPEG files only.")
|
||||
|
||||
exif_datetime, xmp_date, precision_label = _normalise_datetime(
|
||||
precision=precision,
|
||||
year=year,
|
||||
month=month,
|
||||
day=day,
|
||||
time_value=time_value,
|
||||
)
|
||||
|
||||
try:
|
||||
raw_keywords = json.loads(keywords_json)
|
||||
if not isinstance(raw_keywords, list):
|
||||
raise ValueError
|
||||
keywords = [str(value).strip() for value in raw_keywords if str(value).strip()]
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=422, detail="Keywords must be a JSON array.") from exc
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="photo-date-editor-") as temp_dir:
|
||||
temp_path = Path(temp_dir) / f"working{suffix}"
|
||||
await _save_upload(file, temp_path)
|
||||
|
||||
command = [
|
||||
"exiftool",
|
||||
"-overwrite_original",
|
||||
"-m",
|
||||
f"-EXIF:DateTimeOriginal={exif_datetime}",
|
||||
f"-EXIF:CreateDate={exif_datetime}",
|
||||
f"-EXIF:ModifyDate={exif_datetime}",
|
||||
f"-XMP-exif:DateTimeOriginal={exif_datetime}",
|
||||
f"-XMP-xmp:CreateDate={exif_datetime}",
|
||||
f"-XMP-xmp:ModifyDate={exif_datetime}",
|
||||
f"-XMP-photoshop:DateCreated={xmp_date}",
|
||||
f"-XMP-photoshop:Instructions=Photo Date Editor precision: {precision_label}",
|
||||
"-EXIF:ImageDescription=",
|
||||
"-XMP-dc:Description=",
|
||||
"-IPTC:Caption-Abstract=",
|
||||
"-XMP-dc:Subject=",
|
||||
"-IPTC:Keywords=",
|
||||
]
|
||||
|
||||
clean_description = description.strip()
|
||||
if clean_description:
|
||||
command.extend(
|
||||
[
|
||||
f"-EXIF:ImageDescription={clean_description}",
|
||||
f"-XMP-dc:Description={clean_description}",
|
||||
f"-IPTC:Caption-Abstract={clean_description}",
|
||||
]
|
||||
)
|
||||
|
||||
for keyword in keywords:
|
||||
command.append(f"-XMP-dc:Subject+={keyword}")
|
||||
command.append(f"-IPTC:Keywords+={keyword}")
|
||||
|
||||
command.append(str(temp_path))
|
||||
|
||||
logger.info("Processing %s with precision %s", filename, precision)
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=90,
|
||||
check=False,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
logger.error("ExifTool failed: %s", completed.stderr.strip())
|
||||
raise HTTPException(status_code=500, detail="ExifTool could not update this image.")
|
||||
|
||||
updated_bytes = temp_path.read_bytes()
|
||||
|
||||
return Response(
|
||||
content=updated_bytes,
|
||||
media_type="image/jpeg",
|
||||
headers={
|
||||
"Content-Disposition": f'inline; filename="{Path(filename).name}"',
|
||||
"Cache-Control": "no-store",
|
||||
"X-Date-Precision": precision_label,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/service-worker.js", include_in_schema=False)
|
||||
def service_worker() -> FileResponse:
|
||||
return FileResponse(STATIC_DIR / "service-worker.js", media_type="application/javascript")
|
||||
|
||||
|
||||
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
|
||||
@@ -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) => ({
|
||||
'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'
|
||||
})[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();
|
||||
@@ -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 & 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 & 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>
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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))
|
||||
);
|
||||
});
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
services:
|
||||
photo-date-editor:
|
||||
build: .
|
||||
container_name: photo-date-editor
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "${APP_PORT:-8080}:8080"
|
||||
environment:
|
||||
APP_URL: "${APP_URL:-http://localhost:8080}"
|
||||
APP_NAME: "${APP_NAME:-Photo Date Editor}"
|
||||
MAX_UPLOAD_MB: "${MAX_UPLOAD_MB:-150}"
|
||||
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/api/health', timeout=3)"
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
@@ -0,0 +1,3 @@
|
||||
fastapi>=0.116,<1
|
||||
uvicorn[standard]>=0.35,<1
|
||||
python-multipart>=0.0.20,<1
|
||||
Reference in New Issue
Block a user