20 Commits
Author SHA1 Message Date
dmo 1f11aa28b4 Update README.md
Updated after namechange
2026-08-04 13:14:14 +02:00
dmo 6e8da87271 Update README.md
Added AI disclaimer
2026-08-04 10:37:24 +02:00
dmo ad52a95d4f Add saved pixel rotation for JPG, PNG and TIFF v0.9.2 2026-07-25 10:42:25 +02:00
dmo 34602e4ebd Fix desktop viewport layout and navigation visibility v0.9.1 2026-07-25 09:27:10 +02:00
dmo 6e8c30c1d4 Add Nikon and Sony RAW support v0.9 2026-07-25 08:41:41 +02:00
dmo f1704c9822 Stream RAW saves with live progress v0.8.3 2026-07-25 08:38:39 +02:00
dmo 2dee67021e Add binary RAW inspection and diagnostics v0.8.2 2026-07-25 08:37:29 +02:00
dmo d17bc6a132 Optimize RAW inspection and add timings v0.8.1 2026-07-25 08:35:22 +02:00
dmo 4e3c442b26 Add Canon CR2 and CR3 support for v0.8 2026-07-25 08:32:24 +02:00
dmo 8ed5490afc Add configurable formats and lazy decoders, v0.7.1 2026-07-23 21:51:37 +02:00
dmo b9a0259166 Add DNG support v0.7 2026-07-23 21:33:20 +02:00
dmo c2640cf75a Add configurable title and subtitle v0.6.1 2026-07-23 13:18:19 +02:00
dmo 47b8d42295 Added HEIC, HEIF and HIF support v0.6 2026-07-23 12:59:49 +02:00
dmo 3acf6bb7ec Add PNG and TIFF support v0.5 2026-07-23 11:23:49 +02:00
dmo 0541790dba Load favorite location on startup v0.4.3 2026-07-23 09:57:52 +02:00
dmo e250a83b6d Add shared favorite location v0.4.2 2026-07-23 09:54:25 +02:00
dmo 9b7d302064 Fix map loading and tab navigation v0.4.1 2026-07-23 09:50:45 +02:00
dmo e290bd1fff Add geotagging and location map v0.4 2026-07-23 09:44:07 +02:00
dmo cbf0daf062 Add status filtering and segmented progress v0.3 2026-07-23 01:13:15 +02:00
dmo e2f5c91138 Add metadata display and skipped state v0.2 2026-07-23 01:12:10 +02:00
14 changed files with 2526 additions and 177 deletions
+23
View File
@@ -7,5 +7,28 @@ APP_PORT=8080
# Optional settings
APP_NAME=Photo Date Editor
# Optional visible page heading. Falls back to APP_NAME when omitted.
APP_TITLE=Mork Photo Date Editor
# Optional text shown directly below the page heading.
APP_SUBTITLE=Family photo metadata editor
# Enabled groups: jpg, png, tiff, heif, dng, canon, nikon, sony
# Use ENABLED_FORMATS=jpg for a JPEG-only workflow.
ENABLED_FORMATS=jpg,png,tiff,heif,dng,canon,nikon,sony
MAX_UPLOAD_MB=150
LOG_LEVEL=INFO
# Optional TIFF, HEIF, and DNG preview limits. Originals are never converted or replaced by previews.
PREVIEW_MAX_EDGE=2400
RAW_PREVIEW_MAX_EDGE=1600
PREVIEW_MAX_PIXELS=300000000
# Optional map/geocoder overrides. Existing .env files may omit these.
# Map tiles are fetched by the backend and served same-origin to the browser.
MAP_TILE_SOURCE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
MAP_ATTRIBUTION=© OpenStreetMap contributors
DEFAULT_MAP_LAT=64.5
DEFAULT_MAP_LON=11.0
DEFAULT_MAP_ZOOM=5
GEOCODER_URL=https://nominatim.openstreetmap.org/search
# Optional custom identification sent to the geocoder. The default uses APP_NAME, version and APP_URL.
# GEOCODER_USER_AGENT=MorkPhotoDateEditor/0.9.2 (+https://edit.mork.fyi)
+3 -1
View File
@@ -1,2 +1,4 @@
.env
__pycache__/
*.py[cod]
.venv/
+214
View File
@@ -0,0 +1,214 @@
# Changelog
## 0.9.2
- Apply pending preview rotations physically when saving JPG, PNG, and TIFF files.
- Show an explicit overlay stating whether rotation will be saved or is preview-only.
- Preserve pending rotation while navigating between photos during the current session.
- Use lossless `jpegtran` rotation for compatible JPEG dimensions.
- Fall back to one high-quality JPEG re-encode when a perfect lossless transform is unavailable.
- Rotate PNG pixels losslessly and reject animated PNG files rather than dropping frames.
- Rotate every page of a TIFF with lossless TIFF compression.
- Restore existing metadata after PNG/TIFF reconstruction, then normalize EXIF/XMP orientation to 1.
- Keep HEIF, DNG, and all camera RAW formats strictly preview-only for rotation.
- Reject unsupported or invalid rotation requests at the backend.
- Add `libjpeg-turbo-progs` to the container for lossless JPEG transforms.
- Preserve all existing metadata editing, format support, viewport, and streamed-save behavior.
## 0.9.1
- Lock the three-column desktop workspace to the available browser viewport height.
- Keep the bottom photo-navigation bar visible without whole-page scrolling.
- Let the photo list and metadata form scroll independently inside their panels.
- Allow the image stage to shrink with shorter laptop displays while preserving aspect ratio.
- Add a compact desktop-height layout for browser viewports 800 pixels tall or shorter.
- Preserve normal document scrolling for tablet and mobile layouts.
- Preserve all v0.9 format, metadata, preview, and streamed-save behavior.
## 0.9.0
- Add the `nikon` format group with NEF and NRW support.
- Add the `sony` format group with ARW and ARQ support.
- Add exact NEF, NRW, ARW, and ARQ badges and format-sort compatibility.
- Validate the TIFF headers used by Nikon and Sony RAW containers before processing.
- Prefer embedded JPEG previews before loading the lazy LibRaw fallback.
- Read and write EXIF, XMP, IPTC, dates, descriptions, keywords, GPS coordinates, and location labels.
- Preserve Nikon and Sony maker notes, sensor data, embedded previews, and untouched camera metadata.
- Use the established streamed save path, live transfer progress, and timing diagnostics.
- Document every `ENABLED_FORMATS` group, its extensions, and its behavior in the README.
- Keep older Sony SR2 as a future compatibility decision and exclude read-only SRF.
- Preserve all existing JPG, PNG, TIFF, HEIF, DNG, and Canon RAW behavior.
## 0.8.3
- Stream processed files from the backend in 256 KiB chunks with an explicit content length.
- Write response chunks directly into the browser's protected writable file instead of creating a complete in-memory Blob.
- Show live transferred bytes and percentage while saving.
- Commit the local replacement only after the complete response stream closes successfully.
- Cancel the response reader and abort the browser writable file if streaming fails.
- Retain the buffered Blob workflow as a compatibility fallback for browsers without response streaming.
- Report time to first byte and combined stream/write time for streamed saves.
- Preserve all existing metadata, format, progress, and folder-state behavior.
## 0.8.2
- Return inspection metadata and the JPEG preview in one compact binary response instead of Base64 JSON.
- Remove Base64 response expansion and the corresponding browser decode loop.
- Preserve the detailed save timing after the current photo reloads.
- Include total, server/transfer, and local-write durations in the save notification.
- Log end-to-end server duration for `/api/inspect` and `/api/process`.
- Log temporary-copy, preview-extraction, and metadata-read timings for inspections.
- Add request-duration and `Server-Timing` response headers for browser diagnostics.
- Preserve all v0.8.1 RAW opening optimizations and existing format behavior.
## 0.8.1
- Upload server-preview formats only once when opening them, returning the preview and metadata together.
- Stop at the first usable embedded RAW preview instead of extracting every candidate.
- Add `RAW_PREVIEW_MAX_EDGE` (default `1600`) for smaller temporary RAW previews.
- Keep LibRaw as a lazy fallback only when no embedded RAW preview is usable.
- Show separate server/transfer and local-file-write durations after saving.
- Log upload, ExifTool rewrite, and response-read timings and expose them through `Server-Timing`.
- Allow up to three minutes for large DNG and Canon RAW metadata rewrites.
- Preserve all existing format and in-place overwrite behavior.
## 0.8.0
- Add the `canon` format group with CR2 and CR3 support.
- Add exact `CR2` and `CR3` badges and format-sort compatibility.
- Validate TIFF-based CR2 headers separately from ISO-BMFF-based CR3 headers.
- Extract `JpgFromRaw`, `PreviewImage`, and `ThumbnailImage`, using the largest valid embedded preview.
- Use the lazy LibRaw decoder only when a Canon RAW file has no usable embedded preview.
- Read and write CR2 EXIF, XMP, IPTC, date precision, description, keywords, GPS coordinates, and location labels.
- Read and write CR3 EXIF and XMP metadata without applying unsupported IPTC-IIM fields.
- Preserve Canon maker notes, sensor data, embedded previews, and other untouched camera metadata.
- Update enabled-format configuration, UI text, manifest, README, and the RAW roadmap, including planned Nikon and Sony phases.
- Preserve all established JPG, PNG, TIFF, HEIF, and DNG behavior.
- Bump the application and service-worker cache version to 0.8.0.
## 0.7.1
- Add `ENABLED_FORMATS` with the available groups `jpg`, `png`, `tiff`, `heif`, and `dng`.
- Expand each group to its associated extensions and expose the enabled groups/extensions through `/api/config`.
- Omit disabled formats from browser folder scans and reject them at backend metadata, preview, and processing endpoints.
- Generate the default page subtitle and empty-folder guidance from the enabled format groups.
- Validate configuration strictly so unknown or empty format lists fail clearly at startup.
- Load `pillow-heif` only on the first HEIF preview.
- Prefer an embedded DNG preview without loading LibRaw, and import `rawpy` only when a rendered DNG fallback is actually required.
- Keep a decoder loaded after first use until container restart, avoiding repeated load/unload overhead.
- Preserve all format behavior when `ENABLED_FORMATS` is omitted.
- Bump the application and service-worker cache version to 0.7.1.
## 0.7.0
- Add DNG folder scanning, `DNG` format badges, and format-sort compatibility.
- Add browser-friendly DNG previews by preferring embedded `JpgFromRaw`, `PreviewImage`, and `ThumbnailImage` data.
- Add a half-size LibRaw rendering fallback for DNG files without a usable embedded preview.
- Add `rawpy` and the container OpenMP runtime required by its LibRaw decoder.
- Validate DNG uploads as TIFF-based files before processing.
- Read and write DNG EXIF, XMP, IPTC, date precision, description, keywords, GPS coordinates, and location label metadata.
- Preserve the original DNG container, sensor data, and embedded previews while overwriting the same local file.
- Update the interface, manifest, environment example, README, and format roadmap.
- Preserve all established JPG, PNG, TIFF, and HEIF processing behavior.
- Bump the application and service-worker cache version to 0.7.0.
## 0.6.1
- Add optional `APP_TITLE` and `APP_SUBTITLE` environment variables for the visible page heading and subtitle.
- Keep `APP_NAME` backward-compatible as the title fallback and application identity.
- Update the browser tab title from the configured page title.
- Bump the application and service-worker cache version to 0.6.1.
## 0.6.0
- Add HEIC, HEIF, and HIF folder scanning and in-place metadata processing.
- Normalize all three HEIF-family extensions to a `HEIF` format badge and include them in format sorting.
- Generate temporary browser-friendly JPEG previews for HEIF-family images while preserving and overwriting the original container on save.
- Add `pillow-heif` and the container HEIF runtime needed to decode previews.
- Validate HEIF-family files as ISO Base Media File Format containers with recognized HEVC image brands before processing, without admitting renamed AVIF files.
- Read and write HEIF EXIF/XMP dates, precision marker, description, keywords, GPS coordinates, and location label without applying IPTC-IIM fields.
- Return updated files using HEIC/HEIF media types while keeping the original filename and extension.
- Update the interface, manifest, documentation, and empty-folder guidance for the new formats.
- Preserve the established JPG, PNG, and TIFF processing paths.
- Bump the application and service-worker cache version to 0.6.0.
## 0.5.0
- Add in-place PNG metadata reading and writing.
- Add in-place TIFF/TIF metadata reading and writing.
- Generate temporary browser-friendly JPEG previews for TIFF files while preserving and overwriting the original TIFF on save.
- Validate JPEG, PNG, and TIFF file signatures before metadata processing.
- Add normalized JPG, PNG, and TIFF format badges to the left photo list.
- Show the current file format alongside size and modified time in the viewer.
- Add **Sort: Format**, grouping by normalized format and then filename.
- Keep TIFF list thumbnails lazy: a format tile is shown until that TIFF has been opened and previewed.
- Add configurable TIFF preview edge and pixel limits.
- Bump the application and service-worker cache version to 0.5.0.
## 0.4.3
- Fix favorite locations appearing empty after a browser refresh.
- Load the shared server-side favorite list during application startup instead of waiting for **Open photo folder**.
- Keep the existing refresh when opening a folder as a defensive re-sync for changes made by another family member.
- Preserve all favorites already stored in the `photo-date-editor-data` Docker volume.
- Bump the application and service-worker cache version to 0.4.3.
## 0.4.2
- Added shared favorite locations to the Location tab.
- Favorite names and coordinates are stored server-side in the persistent `photo-date-editor-data` Docker volume.
- Added **Use**, **Add current location**, and **Delete selected** controls for favorite locations.
- Favorite locations are available to every browser and family member using the same Docker stack.
- Duplicate favorite names are rejected case-insensitively.
- Documented MP4 as a later media phase after the planned photo formats.
## 0.4.1
- Fix `Shift+Tab` so it activates the previous metadata tab immediately and focuses that tab's first field.
- Keep Left/Right arrow keys dedicated to previous/next photo navigation.
- Remove the tab-header Arrow/Home/End behavior that conflicted with photo navigation and was awkward on compact Mac keyboards.
- Proxy OpenStreetMap tiles through the backend and serve them same-origin through Caddy, avoiding Brave third-party tile blocking.
- Add an in-memory map-tile cache and clear map loading/error status text.
- Add an explicit message when the Leaflet library fails to load instead of silently showing a dead map.
- Add `ca-certificates` explicitly to the container image for outbound HTTPS map and geocoder requests.
- Keep all map environment variables optional; existing `.env` files remain valid.
- Bump the application and service-worker cache version to 0.4.1.
## 0.4.0
- Add a dedicated Location tab with an interactive Leaflet map.
- Add user-triggered address/place search through a backend geocoder endpoint.
- Add click-to-place and draggable map markers.
- Add editable decimal latitude and longitude fields plus an optional location label.
- Read existing EXIF/XMP GPS metadata and repopulate the map and fields.
- Write GPS coordinates to EXIF and XMP while preserving existing GPS unless the user changes it.
- Add explicit Clear location behavior that removes app-managed GPS and location tags on save.
- Add configurable tile, geocoder, and default map settings through optional environment variables.
- Rate-limit uncached public geocoder calls to one request per second and cache results in memory.
- Add `Shift+Tab` to cycle backward through right-side metadata tabs while keeping normal `Tab` field navigation.
- Add arrow-key navigation when a tab header has focus.
- Avoid service-worker caching of third-party map tiles and scripts.
- Bump the application and service-worker cache version to 0.4.0.
## 0.3.0
- Add status filtering for All, Pending, Saved, Skipped, and Failed photos.
- Add sorting by filename or status; status order prioritizes Pending, then Failed, Skipped, and Saved.
- Make previous/next and Save & Next follow the currently filtered and sorted list.
- Replace the single blue progress fill with green Saved, yellow Skipped, and red Failed segments; Pending remains the grey remainder.
- Add accessible progress text with counts for every status.
- Document the proposed PNG, TIFF, HEIC/HEIF, DNG, Canon RAW, and Nikon RAW roadmap.
- Explicitly drop DWG and BMP alongside the previously excluded formats.
- Bump the service-worker cache and application version to 0.3.0.
## 0.2.0
- Read EXIF/XMP/IPTC metadata from the selected JPEG and populate the editor fields.
- Recognize files previously edited by Photo Date Editor from their XMP marker, including in another browser.
- Show a saved metadata summary such as date, time precision, description presence, and keyword count.
- Add a persistent Skipped state and sidebar icon.
- Count Saved + Skipped photos as processed in the progress bar.
- Rename the secondary save action to **Save current**.
- Clarify `Ctrl+S` as **Save current photo and stay on it**.
- Record whether an entered time was known or unknown in the app's XMP marker.
- Preserve compatibility with progress state and files created by 0.1.
+34 -1
View File
@@ -4,7 +4,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends libimage-exiftool-perl \
&& apt-get install -y --no-install-recommends ca-certificates libgomp1 libheif1 libimage-exiftool-perl libjpeg-turbo-progs \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
@@ -14,6 +14,39 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./
ARG LEAFLET_VERSION=1.9.4
RUN LEAFLET_VERSION="$LEAFLET_VERSION" python - <<'PY'
from pathlib import Path
import os
import urllib.request
version = os.environ["LEAFLET_VERSION"]
base = Path("/app/static/vendor/leaflet")
(base / "images").mkdir(parents=True, exist_ok=True)
files = {
"leaflet.css": "leaflet.css",
"leaflet.js": "leaflet.js",
"images/marker-icon.png": "images/marker-icon.png",
"images/marker-icon-2x.png": "images/marker-icon-2x.png",
"images/marker-shadow.png": "images/marker-shadow.png",
}
mirrors = (
f"https://unpkg.com/leaflet@{version}/dist",
f"https://cdn.jsdelivr.net/npm/leaflet@{version}/dist",
)
for destination, source in files.items():
target = base / destination
last_error = None
for mirror in mirrors:
try:
urllib.request.urlretrieve(f"{mirror}/{source}", target)
break
except Exception as exc: # build-time fallback to the second CDN
last_error = exc
else:
raise RuntimeError(f"Could not download Leaflet asset {source}: {last_error}")
PY
EXPOSE 8080
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080", "--proxy-headers", "--forwarded-allow-ips=*"]
-24
View File
@@ -1,24 +0,0 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org/>
+161 -23
View File
@@ -1,27 +1,47 @@
# Photo Date Editor
# mExifEditor
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.
> [!Important]
This project was written 100% by AI for my needs. Feel free to use it if it fits your needs.\
The only things I've done as a human is to tell it what to make, how to modify to fit my needs and which direction to go.\
Project is shared as-is since it covers my needs.
Version 0.9.2
A self-hosted browser UI for manually dating scanned photographs. The browser receives temporary read/write access to a local computer or Chromebook folder, sends one image 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
- JPG/JPEG, PNG, TIFF/TIF, HEIC/HEIF/HIF, DNG, Canon CR2/CR3, Nikon NEF/NRW, and Sony ARW/ARQ
- Local directory picker in supported Chromium browsers
- Three-column photo workflow
- Exact date, month/year, year-only, approximate year
- Optional time; unknown time defaults to noon
- Description and keywords
- GPS geotagging with address search, map click, draggable pin, manual coordinates, and location removal
- Shared favorite locations persisted in a Docker volume for all family members and browsers
- In-place overwrite: no browser download and no `_original` file
- Saved/pending/failed state stored in browser local storage
- Keyboard navigation
- Saved/skipped/pending/failed state stored in browser local storage
- Filename search, status filtering, and filename/status/format sorting
- Normalized JPG, PNG, TIFF, HEIF, DNG, CR2, CR3, NEF, NRW, ARW, and ARQ format badges in the photo list
- Segmented progress bar: saved is green, skipped is yellow, failed is red, and pending remains grey
- Existing metadata is read from supported files when selected
- Files previously edited by this app are recognized from their XMP marker, even in a different browser
- Keyboard navigation, including `Shift+Tab` between right-side tabs
## Preview behavior
JPG and PNG are displayed directly by the browser. Chromium browsers do not reliably display TIFF, HEIF-family images, DNG, CR2, CR3, NEF, NRW, ARW, or ARQ, so those formats are temporarily uploaded to `/api/inspect`, which returns both metadata and a browser-friendly JPEG preview from one upload. The response uses a compact length-prefixed binary envelope rather than Base64 JSON. TIFF uses Pillow, HEIF uses `pillow-heif`, and RAW formats stop at the first usable embedded preview in a container-specific priority order. If none is usable, LibRaw renders a half-size preview through `rawpy`. The preview exists only in memory and is never written over the source file.
When a server-preview format is saved, ExifTool updates the original container and the browser overwrites the same local file through the streamed save path. The original is not converted to JPEG and no additional copy is intentionally left behind.
## Requirements
- Docker with Docker Compose
- A current Chromium browser such as Chrome or Edge
- A current Chromium browser with the File System Access API enabled, such as Chrome, Edge, or Brave with the relevant flag enabled
- 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.
The browser File System Access API is required. Firefox and Safari are not supported by this version.
## Install
@@ -37,12 +57,69 @@ Example `.env`:
APP_URL=https://photos.example.internal
APP_PORT=8080
APP_NAME=Photo Date Editor
APP_TITLE=Mork Photo Date Editor
APP_SUBTITLE=Family photo metadata editor
ENABLED_FORMATS=jpg,png,tiff,heif,dng,canon,nikon,sony
MAX_UPLOAD_MB=150
LOG_LEVEL=INFO
# Optional TIFF/HEIF/RAW preview limits
PREVIEW_MAX_EDGE=2400
RAW_PREVIEW_MAX_EDGE=1600
PREVIEW_MAX_PIXELS=300000000
# Optional map defaults and service overrides
MAP_TILE_SOURCE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
MAP_ATTRIBUTION=&copy; OpenStreetMap contributors
DEFAULT_MAP_LAT=64.5
DEFAULT_MAP_LON=11.0
DEFAULT_MAP_ZOOM=5
GEOCODER_URL=https://nominatim.openstreetmap.org/search
```
Open the configured HTTPS URL through Caddy, click **Open photo folder**, and grant read/write access.
## Enabled formats
`ENABLED_FORMATS` accepts a comma-separated list of format groups:
| `.env` group | Formats enabled | Preview and metadata behavior |
|---|---|---|
| `jpg` | JPG/JPEG: `.jpg`, `.jpeg` | Browser-native preview; EXIF, XMP, and IPTC |
| `png` | PNG: `.png` | Browser-native preview; EXIF and XMP |
| `tiff` | TIFF: `.tif`, `.tiff` | Server JPEG preview; EXIF, XMP, and IPTC |
| `heif` | HEIF family: `.heic`, `.heif`, `.hif` | Lazy HEIF decoder; EXIF and XMP |
| `dng` | Adobe DNG: `.dng` | Embedded preview or lazy LibRaw; EXIF, XMP, and IPTC |
| `canon` | Canon RAW: `.cr2`, `.cr3` | Embedded preview or lazy LibRaw; CR2 uses EXIF/XMP/IPTC, CR3 uses EXIF/XMP |
| `nikon` | Nikon RAW: `.nef`, `.nrw` | Embedded preview or lazy LibRaw; EXIF, XMP, and IPTC |
| `sony` | Sony RAW: `.arw`, `.arq` | Embedded preview or lazy LibRaw; EXIF, XMP, and IPTC |
The default enables every group:
```dotenv
ENABLED_FORMATS=jpg,png,tiff,heif,dng,canon,nikon,sony
```
For a JPEG-only installation:
```dotenv
ENABLED_FORMATS=jpg
```
Disabled formats are omitted from folder scans and rejected by the backend. The page subtitle and empty-folder message reflect the enabled groups. Unknown group names stop application startup with a clear configuration error instead of silently enabling or disabling the wrong format.
HEIF and RAW decoders load lazily. A JPEG-only process does not import them. After the first applicable preview, the decoder remains loaded until the container restarts; repeatedly unloading it would add avoidable delay and memory churn. For DNG, Canon, Nikon, and Sony RAW, LibRaw is loaded only if no usable embedded JPEG preview is available.
## Upgrade from earlier releases
Replace the project files with this version and rebuild:
```bash
docker compose up -d --build
```
The existing `.env` can be kept. When `ENABLED_FORMATS` is omitted, all current format groups remain enabled. `APP_TITLE` and `APP_SUBTITLE` are optional. When `APP_TITLE` is omitted, the visible page title continues to use `APP_NAME`; when `APP_SUBTITLE` is omitted, it is generated from the enabled formats. Browser progress from earlier releases remains compatible. A normal refresh should load the new service-worker cache; use a hard refresh if an old UI remains visible.
## Caddy example
Caddy may run in another container or host. Proxy to the Docker host and exposed port:
@@ -59,18 +136,38 @@ photos.example.internal {
}
```
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.
The browser directory picker requires a secure context. Use a certificate trusted by the client computer. 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.
1. The browser reads the selected local file in any enabled format.
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()`.
4. The backend returns the modified file using its original media type.
5. The browser 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.
No second photo is intentionally left on the client computer or Docker host.
RAW saves still require the complete original to travel to Docker and the complete rewritten file to return to the browser. ExifTool must also rewrite the metadata-bearing RAW container. The optimized opening path removes duplicate uploads, but the full save round trip is inherent while the folder belongs to the browser rather than the Docker host.
During a save, the backend streams the processed file in 256 KiB chunks and the browser writes each chunk directly into its protected temporary writable file. The UI shows transferred bytes and percentage. The original is replaced only after the complete stream closes successfully; failures abort the temporary writable file. Browsers without response streaming use the previous buffered Blob workflow.
After a streamed save, the status panel and popup show total time, time to first byte, and combined stream/write time. Container logs also show end-to-end request duration and break inspection work into temporary-copy, preview, and metadata stages.
## Metadata behavior by format
- **JPG/JPEG:** EXIF, XMP, and IPTC fields are written.
- **TIFF/TIF:** EXIF, XMP, and IPTC fields are written.
- **PNG:** EXIF and XMP fields are written. PNG metadata support varies more between third-party viewers than JPEG/TIFF support, so test the applications that will consume the files.
- **HEIC/HEIF/HIF:** EXIF and XMP fields are written. IPTC-IIM is not used for these ISO Base Media File Format containers. Date precision, description, keywords, GPS coordinates, and the location label remain represented through EXIF/XMP fields.
- **DNG:** EXIF, XMP, and IPTC fields are written into the original TIFF-based DNG container. Raw sensor data and embedded previews are not regenerated or replaced.
- **CR2:** EXIF, XMP, and IPTC fields are written into the original TIFF-based Canon RAW container.
- **CR3:** EXIF and XMP fields are written into the original ISO Base Media File Format container. IPTC-IIM is not used for CR3.
- **NEF/NRW:** EXIF, XMP, and IPTC fields are written into the original TIFF-based Nikon RAW container.
- **ARW/ARQ:** EXIF, XMP, and IPTC fields are written into the original TIFF-based Sony RAW container.
The app writes its precision marker to XMP for all supported formats. GPS coordinates are written to EXIF and XMP. The optional location label is written to XMP IPTC Core Location.
## Date behavior
@@ -88,24 +185,65 @@ The chosen precision is also written into XMP Photoshop Instructions.
## Keyboard shortcuts
- `Enter` or `Ctrl+Enter`: Save & Next
- `Ctrl+S`: Save without moving
- `Ctrl+S`: Save the current photo and remain on it
- `Left` / `Right`: Previous / next photo when not typing
- `S`: Skip when not typing
- `S`: Mark the photo as skipped and open the next photo when not typing
- `C`: Copy previous values when not typing
- `Shift+Tab`: Open the previous right-side metadata tab and place focus in its first field; normal `Tab` still advances through fields
## Location behavior
Favorite locations are loaded automatically when the page opens and are shared by everyone using the same Docker stack. Choose a saved place and click **Use**, or place a pin/set coordinates and click **Add current location** in the favorite-locations card. The favorite list is stored in the named Docker volume `photo-date-editor-data`, so it survives image rebuilds and container recreation. Deleting a favorite only removes it from the reusable list; it does not alter photos that were already geotagged.
The **Location** tab supports five ways to set GPS metadata:
1. Choose a shared favorite location.
2. Search for an address or place and choose a result.
3. Click directly on the map.
4. Drag the existing marker.
5. Enter decimal latitude and longitude manually.
The default map tiles and address search are external OpenStreetMap services. Both are requested by the Docker backend rather than directly by the browser. Tiles are cached in container memory. Search is user-triggered, rate-limited, and cached in memory. The tile source and geocoder URLs remain configurable in `.env`.
## Important limitations
- The app cannot silently access a Chromebook folder. The user must choose it and approve read/write access.
- The app cannot silently access a local folder. The user must choose it and approve read/write access.
- Browser permission may need to be granted again after closing/reopening the browser.
- Saved and skipped progress is stored per browser and folder name. The app also reads its own XMP marker from edited files so saved metadata can be recognized on another 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.
- Preview rotations are physically applied when saving JPG, PNG, and TIFF files. Orientation metadata is normalized after the pixels are rotated.
- JPEG rotation uses lossless `jpegtran` when the dimensions permit a perfect transform. Otherwise it performs one high-quality re-encode and reports that in the save notification.
- PNG rotation is lossless. Animated PNG files are deliberately rejected rather than silently dropping frames.
- TIFF rotation preserves and rotates all pages, uses lossless TIFF compression, and restores metadata before applying the edited fields.
- HEIF, DNG, CR2, CR3, NEF, NRW, ARW, and ARQ rotation remains preview-only. Their pixels and orientation metadata are never changed by the rotation controls.
- Multi-page TIFF files display the first page/frame in the editor. Metadata is written to the TIFF container, not to separate pages.
- HEIF image sequences display their primary/first image in the editor. Metadata is written to the HEIF container.
- DNG, CR2, CR3, NEF, NRW, ARW, and ARQ use the first usable embedded JPEG in a format-specific priority order before falling back to a half-size LibRaw render.
- `RAW_PREVIEW_MAX_EDGE` defaults to 1600 pixels to keep temporary previews responsive. It never resizes the RAW original.
- Very large TIFF, HEIF, or RAW files may require raising `MAX_UPLOAD_MB` or `PREVIEW_MAX_PIXELS`.
- RAW rendering depends on the camera and compression variant being supported by the bundled LibRaw version. Metadata editing can still be supported by ExifTool even when a particular RAW file cannot be rendered.
- Canon, Nikon, and Sony maker notes, sensor data, embedded previews, and camera-specific metadata are preserved rather than intentionally rewritten. Always validate with copies from the camera models in your archive.
- [ExifTool documents](https://exiftool.org/#supported) that some Sony Imaging Edge versions may reject ARW/ARQ files after ExifTool edits even though Adobe, Apple, Capture One, Affinity Photo, and LibRaw-based software can open them. Test Sony copies in the exact software you intend to use.
- HEIF metadata compatibility varies between operating-system galleries and photo-management applications. Verify the fields in the software that will consume your library.
- The Docker host needs outbound HTTPS access for the default map tiles and address search.
- Test with copies first, then use your 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:
Format support needs both safe metadata writing and a browser-friendly preview. Formats that browsers do not reliably display will use a temporary server-generated preview without converting or replacing the original file.
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.
### Remaining considerations
Older Sony `SR2` remains a possible compatibility addition. `SRF` is read-only in ExifTool and remains excluded.
`RAW` is not treated as one universal format. Each camera family will be enabled and tested explicitly.
### Not planned
- **DWG** — CAD drawing format rather than a scanned-photo format.
- **BMP/DIB** — ExifTool can read it but cannot write the metadata this app needs.
- **SVG, PDF, EPS, WebP, AVIF, EXR, XCF** — intentionally outside the scope of this photo workflow.
## Future video support
MP4 remains feasible after the planned photo formats, but it will be a separate media phase rather than just another image extension. The existing fields can be mapped to compatible QuickTime and XMP metadata, but large videos need streamed/chunked transfer, progress reporting, larger limits, cancellation, and compatibility testing before in-place support is enabled.
+1072 -23
View File
File diff suppressed because it is too large Load Diff
+814 -70
View File
File diff suppressed because it is too large Load Diff
+99 -16
View File
@@ -6,6 +6,7 @@
<meta name="theme-color" content="#121820">
<title>Photo Date Editor</title>
<link rel="manifest" href="/manifest.webmanifest">
<link rel="stylesheet" href="/vendor/leaflet/leaflet.css">
<link rel="stylesheet" href="/styles.css">
</head>
<body>
@@ -15,7 +16,7 @@
<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>
<p id="app-subtitle">Loading enabled photo formats…</p>
</div>
</div>
<div class="top-actions">
@@ -31,7 +32,7 @@
<div class="folder-row">
<div>
<strong id="folder-name">No folder selected</strong>
<span id="folder-access">Choose a local Chromebook folder</span>
<span id="folder-access">Choose a local folder</span>
</div>
<button id="open-folder" class="button compact">Change</button>
</div>
@@ -42,9 +43,14 @@
<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="progress-track" role="progressbar" aria-label="Photo processing progress" aria-valuemin="0" aria-valuemax="100">
<div id="progress-saved" class="progress-segment saved" title="Saved"></div>
<div id="progress-skipped" class="progress-segment skipped" title="Skipped"></div>
<div id="progress-failed" class="progress-segment failed" title="Failed"></div>
</div>
<div class="status-legend">
<span><i class="dot saved"></i>Saved</span>
<span><i class="dot skipped"></i>Skipped</span>
<span><i class="dot pending"></i>Pending</span>
<span><i class="dot failed"></i>Failed</span>
</div>
@@ -52,9 +58,23 @@
<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 class="file-controls">
<input id="filter-input" class="input filter filename-filter" type="search" placeholder="Filter filenames…" aria-label="Filter photos by filename" disabled>
<select id="status-filter" class="input filter" aria-label="Filter photos by status" disabled>
<option value="all">All statuses</option>
<option value="pending">Pending</option>
<option value="saved">Saved</option>
<option value="skipped">Skipped</option>
<option value="failed">Failed</option>
</select>
<select id="sort-select" class="input filter" aria-label="Sort photos" disabled>
<option value="filename">Sort: Filename</option>
<option value="status">Sort: Status</option>
<option value="format">Sort: Format</option>
</select>
</div>
<div id="file-list" class="file-list" aria-live="polite">
<div class="empty-list">Choose a folder containing JPG or JPEG photos.</div>
<div class="empty-list">Choose a folder containing an enabled photo format.</div>
</div>
</section>
@@ -74,6 +94,7 @@
<div id="viewer-content" class="viewer-content hidden">
<div class="image-stage">
<img id="photo-preview" alt="Current scanned photograph">
<div id="rotation-status" class="rotation-status hidden" role="status"></div>
</div>
<div class="viewer-tools">
<div class="tool-group">
@@ -86,8 +107,8 @@
<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="rotate-left" class="icon-button" title="Rotate left"></button>
<button id="rotate-right" class="icon-button" title="Rotate right"></button>
<button id="reset-view" class="icon-button" title="Reset preview"></button>
</div>
</div>
@@ -96,7 +117,7 @@
<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-button" class="button">Save current</button>
<button id="save-next-button" class="button primary">Save &amp; Next →</button>
</div>
</div>
@@ -105,13 +126,14 @@
<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>
<button id="tab-date" class="tab active" data-tab="date" role="tab" aria-selected="true" aria-controls="panel-date">Date &amp; time</button>
<button id="tab-details" class="tab" data-tab="details" role="tab" aria-selected="false" aria-controls="panel-details" tabindex="-1">Details</button>
<button id="tab-keywords" class="tab" data-tab="keywords" role="tab" aria-selected="false" aria-controls="panel-keywords" tabindex="-1">Keywords</button>
<button id="tab-location" class="tab" data-tab="location" role="tab" aria-selected="false" aria-controls="panel-location" tabindex="-1">Location</button>
</div>
<form id="metadata-form" autocomplete="off">
<section class="tab-panel active" data-panel="date">
<section id="panel-date" class="tab-panel active" data-panel="date" role="tabpanel" aria-labelledby="tab-date">
<fieldset>
<legend>Date precision</legend>
<label class="radio-row"><input type="radio" name="precision" value="exact" checked> <span>Exact date</span></label>
@@ -160,7 +182,7 @@
</div>
</section>
<section class="tab-panel" data-panel="details">
<section id="panel-details" class="tab-panel" data-panel="details" role="tabpanel" aria-labelledby="tab-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>
@@ -168,13 +190,72 @@
<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">
<section id="panel-keywords" class="tab-panel" data-panel="keywords" role="tabpanel" aria-labelledby="tab-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>
<section id="panel-location" class="tab-panel" data-panel="location" role="tabpanel" aria-labelledby="tab-location">
<div class="favorite-locations">
<div class="section-heading-row">
<div>
<strong>Favorite locations</strong>
<span>Shared by everyone using this Docker stack</span>
</div>
</div>
<div class="favorite-controls">
<select id="favorite-location-select" class="input" aria-label="Favorite locations">
<option value="">No favorites saved</option>
</select>
<button id="use-favorite-location" class="button" type="button" disabled>Use</button>
</div>
<div class="favorite-actions">
<button id="save-favorite-location" class="button ghost" type="button">★ Add current location</button>
<button id="delete-favorite-location" class="button ghost danger-text" type="button" disabled>Delete selected</button>
</div>
</div>
<div class="location-search">
<label class="field location-search-field">
<span>Search for a place or address</span>
<input id="address-search" class="input" type="search" placeholder="Cabin address, town, landmark…">
</label>
<button id="address-search-button" class="button" type="button">Search</button>
</div>
<p class="field-help search-privacy">Search queries are sent to the configured geocoder. You can place a pin manually instead.</p>
<div id="address-results" class="address-results" aria-live="polite"></div>
<div id="location-map" class="location-map" aria-label="Map for selecting photo location"></div>
<p id="map-status" class="field-help map-status" role="status">Open this tab to load the map.</p>
<p class="field-help map-help">Click the map to place a pin, or drag the pin to fine-tune the position.</p>
<div class="field-grid coordinate-grid">
<label class="field">
<span>Latitude</span>
<input id="latitude-input" class="input" type="number" min="-90" max="90" step="0.000001" placeholder="60.391263">
</label>
<label class="field">
<span>Longitude</span>
<input id="longitude-input" class="input" type="number" min="-180" max="180" step="0.000001" placeholder="5.322054">
</label>
</div>
<label class="field">
<span>Location label</span>
<input id="location-name-input" class="input" type="text" maxlength="500" placeholder="Cabin, Voss, Norway">
</label>
<div class="location-actions">
<button id="clear-location" class="button ghost" type="button">Clear location</button>
</div>
<div class="info-card location-info">
<strong>What gets written</strong>
<p>Coordinates are written to EXIF and XMP GPS fields. The optional label is written to XMP Location.</p>
</div>
</section>
</form>
<section class="right-footer">
@@ -192,14 +273,16 @@
<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>Ctrl+S</dt><dd>Save current photo and stay on it</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>S</dt><dd>Mark skipped and open next photo</dd></div>
<div><dt>C</dt><dd>Copy previous photo's values</dd></div>
<div><dt>Shift+Tab</dt><dd>Open the previous metadata tab and focus its first field</dd></div>
</dl>
</dialog>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="/vendor/leaflet/leaflet.js"></script>
<script src="/app.js" type="module"></script>
</body>
</html>
+1 -1
View File
@@ -5,5 +5,5 @@
"display": "standalone",
"background_color": "#0d1218",
"theme_color": "#121820",
"description": "Edit dates, descriptions, and keywords in scanned JPEG photos."
"description": "Edit dates, descriptions, keywords, and locations in common images, DNG, and Canon RAW photos."
}
+9 -3
View File
@@ -1,5 +1,5 @@
const CACHE_NAME = 'photo-date-editor-v1';
const SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest'];
const CACHE_NAME = 'photo-date-editor-v9-2';
const SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest', '/vendor/leaflet/leaflet.css', '/vendor/leaflet/leaflet.js'];
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL)));
@@ -14,7 +14,13 @@ self.addEventListener('activate', (event) => {
});
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET' || new URL(event.request.url).pathname.startsWith('/api/')) return;
const url = new URL(event.request.url);
if (
event.request.method !== 'GET'
|| url.origin !== self.location.origin
|| url.pathname.startsWith('/api/')
) return;
event.respondWith(
fetch(event.request).then((response) => {
const copy = response.clone();
+84 -15
View File
@@ -18,20 +18,20 @@
}
* { 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; }
html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; 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); }
.app-shell { width: 100%; height: 100vh; height: 100dvh; min-height: 0; overflow: hidden; display: flex; flex-direction: column; }
.topbar { flex: 0 0 70px; 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; }
.workspace { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(260px, 320px) minmax(480px, 1fr) minmax(330px, 420px); overflow: hidden; }
.sidebar { background: var(--panel); min-height: 0; overflow: hidden; 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); }
@@ -41,32 +41,43 @@ button { color: inherit; }
.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; }
.progress-track { height: 7px; display: flex; background: #2a3440; border-radius: 99px; overflow: hidden; }
.progress-segment { width: 0; height: 100%; flex: 0 0 auto; transition: width .2s ease; }
.progress-segment.saved { background: var(--success); }
.progress-segment.skipped { background: var(--warning); }
.progress-segment.failed { background: var(--danger); }
.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); }
.dot.saved { background: var(--success); }.dot.skipped { background: #c49a5a; }.dot.pending { background: #748294; }.dot.failed { background: var(--danger); }
.file-section { flex: 1; display: flex; min-height: 0; flex-direction: column; }
.file-controls { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.filename-filter { grid-column: 1 / -1; }
.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-thumb.format-placeholder { display: grid; place-items: center; border: 1px solid var(--border); color: var(--muted); font-size: 10px; font-weight: 800; letter-spacing: .06em; }
.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-meta strong { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; }
.file-details { margin-top: 4px; display: flex; align-items: center; gap: 6px; min-width: 0; color: var(--muted); font-size: 11px; white-space: nowrap; }
.file-details > span:not(.format-badge) { overflow: hidden; text-overflow: ellipsis; }
.format-badge { flex: 0 0 auto; padding: 1px 5px; border: 1px solid var(--border); border-radius: 999px; color: var(--text); font-size: 9px; font-weight: 800; letter-spacing: .05em; }
.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.skipped { color: #2e2111; background: #c49a5a; border-color: #c49a5a; 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-panel { min-width: 0; min-height: 0; overflow: hidden; 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%); }
.viewer-content { width: 100%; height: 100%; min-height: 0; overflow: hidden; display: grid; grid-template-rows: minmax(220px, 1fr) auto 76px; }
.image-stage { position: relative; 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; }
.rotation-status { position: absolute; left: 18px; bottom: 18px; max-width: calc(100% - 36px); padding: 7px 10px; border: 1px solid rgba(84,201,138,.55); border-radius: 7px; background: rgba(12,20,27,.9); color: #bcebd0; font-size: 11px; box-shadow: var(--shadow); }
.rotation-status.preview-only { border-color: rgba(228,179,92,.55); color: #f0d59f; }
.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; }
@@ -88,7 +99,7 @@ fieldset { border: 0; padding: 0; margin: 0 0 22px; } legend { font-weight: 650;
.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); }
.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.loading .status-icon, .save-status.saving .status-icon { color: var(--warning); }.save-status.saved .status-icon { color: var(--success); }.save-status.skipped .status-icon { color: #c49a5a; }.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; }
@@ -102,6 +113,64 @@ fieldset { border: 0; padding: 0; margin: 0 0 22px; } legend { font-weight: 650;
.workspace { grid-template-columns: 250px minmax(380px, 1fr) 340px; }
}
@media (max-width: 850px) {
html, body { height: auto; min-height: 100%; overflow: auto; }
.app-shell { height: auto; min-height: 100vh; min-height: 100dvh; overflow: visible; }
.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; }
.workspace { flex: none; min-height: 0; display: block; overflow: visible; }.left-panel, .right-panel { border: 0; overflow: visible; }.left-panel { max-height: 480px; }.viewer-panel { min-height: 620px; overflow: visible; }.viewer-content { min-height: 620px; overflow: visible; }.right-panel { min-height: 600px; }
}
@media (min-width: 851px) and (max-height: 800px) {
.topbar { flex-basis: 60px; height: 60px; }
.panel-section { padding: 13px 16px; }
.image-stage { padding: 10px; }
.viewer-content { grid-template-rows: minmax(180px, 1fr) auto 66px; }
.viewer-tools { min-height: 52px; padding: 7px 14px; }
.navigation-bar { padding: 10px 14px; }
.tab { padding-block: 13px 10px; }
#metadata-form { padding: 15px 18px; }
.sidebar-footer, .right-footer { padding: 12px 16px; }
}
/* v0.4 location editor */
.tabs { grid-template-columns: repeat(4, 1fr); }
.location-search { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 8px; }
.location-search-field { margin-bottom: 0; }
.address-results { display: grid; gap: 6px; margin: 10px 0; max-height: 160px; overflow: auto; }
.address-result { width: 100%; border: 1px solid var(--line); border-radius: 7px; background: #10171f; padding: 9px 10px; text-align: left; cursor: pointer; }
.address-result:hover { border-color: var(--primary); background: #172131; }
.address-result strong, .address-result span { display: block; }
.address-result strong { font-size: 12px; line-height: 1.4; }
.address-result span { margin-top: 3px; color: var(--muted); font-size: 10px; }
.address-results .search-message { color: var(--muted); font-size: 11px; padding: 4px 1px; }
.location-map { height: 310px; min-height: 240px; border: 1px solid var(--line); border-radius: 9px; overflow: hidden; background: #0b1118; }
.location-map:focus-within { outline: 2px solid rgba(79,135,255,.35); }
.map-help { margin: 8px 0 15px; }
.coordinate-grid { grid-template-columns: 1fr 1fr; }
.location-actions { display: flex; justify-content: flex-end; margin: -3px 0 16px; }
.location-info { margin-top: 0; }
.leaflet-container { background: #0b1118; font-family: inherit; }
.leaflet-control-attribution { font-size: 9px; }
.leaflet-popup-content-wrapper, .leaflet-popup-tip { background: #18212b; color: var(--text); }
@media (max-width: 1100px) {
.tab { font-size: 12px; padding-inline: 5px; }
}
.search-privacy { margin: 7px 0 0; }
.map-status { margin-top: 8px; min-height: 1.2em; }
.map-status.error { color: var(--danger); }
/* v0.4.2 shared favorite locations */
.favorite-locations {
margin: 0 0 18px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 9px;
background: rgba(16, 23, 31, .72);
}
.section-heading-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 9px; }
.section-heading-row strong, .section-heading-row span { display: block; }
.section-heading-row strong { font-size: 12px; }
.section-heading-row span { margin-top: 2px; color: var(--muted); font-size: 10px; }
.favorite-controls { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
.favorite-actions { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; margin-top: 8px; }
.danger-text { color: var(--danger); }
+9
View File
@@ -10,8 +10,14 @@ services:
environment:
APP_URL: "${APP_URL:-http://localhost:8080}"
APP_NAME: "${APP_NAME:-Photo Date Editor}"
APP_TITLE: "${APP_TITLE:-}"
APP_SUBTITLE: "${APP_SUBTITLE:-}"
ENABLED_FORMATS: "${ENABLED_FORMATS:-jpg,png,tiff,heif,dng,canon}"
MAX_UPLOAD_MB: "${MAX_UPLOAD_MB:-150}"
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
DATA_DIR: "/data"
volumes:
- photo-date-editor-data:/data
healthcheck:
test:
- CMD
@@ -22,3 +28,6 @@ services:
timeout: 5s
retries: 3
start_period: 10s
volumes:
photo-date-editor-data:
+3
View File
@@ -1,3 +1,6 @@
fastapi>=0.116,<1
uvicorn[standard]>=0.35,<1
python-multipart>=0.0.20,<1
Pillow>=11.3,<13
pillow-heif>=1.1,<2
rawpy>=0.25,<1