Initial JPEG photo metadata editor v0.1

This commit is contained in:
dmo
2026-07-23 01:10:49 +02:00
parent cdc206680c
commit 8738b8c814
12 changed files with 1222 additions and 0 deletions
+11
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
.env
+19
View File
@@ -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=*"]
+111
View File
@@ -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
View File
@@ -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")
+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; }
}
+24
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
fastapi>=0.116,<1
uvicorn[standard]>=0.35,<1
python-multipart>=0.0.20,<1