Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f11aa28b4 | ||
|
|
6e8da87271 | ||
|
|
ad52a95d4f | ||
|
|
34602e4ebd |
+1
-1
@@ -31,4 +31,4 @@ 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.0 (+https://edit.mork.fyi)
|
||||
# GEOCODER_USER_AGENT=MorkPhotoDateEditor/0.9.2 (+https://edit.mork.fyi)
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
# 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.
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates libgomp1 libheif1 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
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# Photo Date Editor
|
||||
# mExifEditor
|
||||
|
||||
Version 0.9.0
|
||||
> [!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.
|
||||
|
||||
@@ -206,7 +211,11 @@ The default map tiles and address search are external OpenStreetMap services. Bo
|
||||
- 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; it does not rotate image pixels or write orientation metadata.
|
||||
- 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.
|
||||
|
||||
+165
-2
@@ -6,6 +6,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
@@ -24,6 +25,7 @@ from pydantic import BaseModel, Field
|
||||
from fastapi.responses import FileResponse, Response, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||
from PIL import ImageSequence, JpegImagePlugin
|
||||
|
||||
FORMAT_GROUPS: dict[str, tuple[str, ...]] = {
|
||||
"jpg": (".jpg", ".jpeg"),
|
||||
@@ -89,7 +91,7 @@ APP_SUBTITLE = (
|
||||
or f"Local folder · in-place {ENABLED_FORMAT_LABEL} metadata"
|
||||
)
|
||||
APP_URL = os.getenv("APP_URL", "http://localhost:8080")
|
||||
APP_VERSION = "0.9.0"
|
||||
APP_VERSION = "0.9.2"
|
||||
MAX_UPLOAD_MB = int(os.getenv("MAX_UPLOAD_MB", "150"))
|
||||
MAX_UPLOAD_BYTES = MAX_UPLOAD_MB * 1024 * 1024
|
||||
PREVIEW_MAX_EDGE = int(os.getenv("PREVIEW_MAX_EDGE", "2400"))
|
||||
@@ -98,6 +100,7 @@ PREVIEW_MAX_PIXELS = int(os.getenv("PREVIEW_MAX_PIXELS", "300000000"))
|
||||
Image.MAX_IMAGE_PIXELS = PREVIEW_MAX_PIXELS
|
||||
|
||||
TIFF_EXTENSIONS = {".tif", ".tiff"}
|
||||
PIXEL_ROTATION_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff"}
|
||||
HEIF_EXTENSIONS = {".heic", ".heif", ".hif"}
|
||||
DNG_EXTENSIONS = {".dng"}
|
||||
CANON_EXTENSIONS = {".cr2", ".cr3"}
|
||||
@@ -788,6 +791,146 @@ def _render_raw(path: Path, format_name: str) -> Image.Image:
|
||||
return Image.fromarray(rgb)
|
||||
|
||||
|
||||
def _transpose_for_clockwise_rotation(image: Image.Image, rotation: int) -> Image.Image:
|
||||
operation = {
|
||||
90: Image.Transpose.ROTATE_270,
|
||||
180: Image.Transpose.ROTATE_180,
|
||||
270: Image.Transpose.ROTATE_90,
|
||||
}[rotation]
|
||||
return image.transpose(operation)
|
||||
|
||||
|
||||
def _copy_metadata(source: Path, destination: Path) -> None:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
"exiftool",
|
||||
"-overwrite_original",
|
||||
"-m",
|
||||
"-TagsFromFile",
|
||||
str(source),
|
||||
"-all:all",
|
||||
str(destination),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=90,
|
||||
check=False,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
logger.error("ExifTool metadata restore after rotation failed: %s", completed.stderr.strip())
|
||||
raise ValueError("Metadata could not be preserved after rotating the image.")
|
||||
|
||||
|
||||
def _rotate_jpeg(source: Path, destination: Path, rotation: int) -> str:
|
||||
with Image.open(source) as image:
|
||||
orientation = int(image.getexif().get(274, 1) or 1)
|
||||
|
||||
if orientation == 1:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
"jpegtran",
|
||||
"-copy",
|
||||
"all",
|
||||
"-perfect",
|
||||
"-rotate",
|
||||
str(rotation),
|
||||
"-outfile",
|
||||
str(destination),
|
||||
str(source),
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=90,
|
||||
check=False,
|
||||
)
|
||||
if completed.returncode == 0 and destination.exists():
|
||||
return "jpeg-lossless"
|
||||
|
||||
with Image.open(source) as image:
|
||||
image.load()
|
||||
corrected = ImageOps.exif_transpose(image)
|
||||
rotated = _transpose_for_clockwise_rotation(corrected, rotation)
|
||||
try:
|
||||
sampling = JpegImagePlugin.get_sampling(image)
|
||||
save_options: dict[str, Any] = {
|
||||
"format": "JPEG",
|
||||
"quality": 95,
|
||||
"optimize": True,
|
||||
"progressive": bool(image.info.get("progressive") or image.info.get("progression")),
|
||||
}
|
||||
if sampling >= 0:
|
||||
save_options["subsampling"] = sampling
|
||||
if image.info.get("icc_profile"):
|
||||
save_options["icc_profile"] = image.info["icc_profile"]
|
||||
rotated.save(destination, **save_options)
|
||||
finally:
|
||||
if corrected is not image:
|
||||
corrected.close()
|
||||
rotated.close()
|
||||
_copy_metadata(source, destination)
|
||||
return "jpeg-reencoded"
|
||||
|
||||
|
||||
def _rotate_png_or_tiff(source: Path, destination: Path, suffix: str, rotation: int) -> str:
|
||||
with Image.open(source) as image:
|
||||
image.seek(0)
|
||||
if suffix == ".png" and int(getattr(image, "n_frames", 1)) > 1:
|
||||
raise ValueError("Animated PNG rotation is not supported.")
|
||||
frames: list[Image.Image] = []
|
||||
for frame in ImageSequence.Iterator(image):
|
||||
base = frame.copy()
|
||||
corrected = ImageOps.exif_transpose(base)
|
||||
if corrected is not base:
|
||||
base.close()
|
||||
rotated = _transpose_for_clockwise_rotation(corrected, rotation)
|
||||
corrected.close()
|
||||
frames.append(rotated)
|
||||
if not frames:
|
||||
raise ValueError("The image contains no rotatable frames.")
|
||||
try:
|
||||
if suffix == ".png":
|
||||
options: dict[str, Any] = {"format": "PNG", "optimize": True}
|
||||
if image.info.get("icc_profile"):
|
||||
options["icc_profile"] = image.info["icc_profile"]
|
||||
if image.info.get("dpi"):
|
||||
options["dpi"] = image.info["dpi"]
|
||||
frames[0].save(destination, **options)
|
||||
mode = "png-lossless"
|
||||
else:
|
||||
options = {
|
||||
"format": "TIFF",
|
||||
"save_all": len(frames) > 1,
|
||||
"append_images": frames[1:],
|
||||
"compression": image.info.get("compression", "tiff_deflate"),
|
||||
}
|
||||
if image.info.get("icc_profile"):
|
||||
options["icc_profile"] = image.info["icc_profile"]
|
||||
if image.info.get("dpi"):
|
||||
options["dpi"] = image.info["dpi"]
|
||||
frames[0].save(destination, **options)
|
||||
mode = "tiff-lossless-reencode"
|
||||
finally:
|
||||
for frame in frames:
|
||||
frame.close()
|
||||
_copy_metadata(source, destination)
|
||||
return mode
|
||||
|
||||
|
||||
def _rotate_pixels(path: Path, suffix: str, rotation: int) -> str:
|
||||
source = path.with_name(f"rotation-source{suffix}")
|
||||
destination = path.with_name(f"rotation-output{suffix}")
|
||||
shutil.copy2(path, source)
|
||||
try:
|
||||
if suffix in {".jpg", ".jpeg"}:
|
||||
mode = _rotate_jpeg(source, destination, rotation)
|
||||
else:
|
||||
mode = _rotate_png_or_tiff(source, destination, suffix, rotation)
|
||||
destination.replace(path)
|
||||
return mode
|
||||
except (Image.DecompressionBombError, UnidentifiedImageError, OSError, ValueError) as exc:
|
||||
logger.warning("Pixel rotation failed for %s: %s", path.name, exc)
|
||||
raise HTTPException(status_code=422, detail=f"The image could not be rotated safely: {exc}") from exc
|
||||
|
||||
|
||||
def _preview_bytes(temp_path: Path, suffix: str, filename: str) -> bytes:
|
||||
try:
|
||||
if suffix in RAW_EXTENSIONS:
|
||||
@@ -939,6 +1082,7 @@ async def process_photo(
|
||||
latitude: Annotated[float | None, Form()] = None,
|
||||
longitude: Annotated[float | None, Form()] = None,
|
||||
location_name: Annotated[str, Form()] = "",
|
||||
rotation: Annotated[int, Form()] = 0,
|
||||
) -> Response:
|
||||
filename = file.filename or "photo.jpg"
|
||||
suffix = Path(filename).suffix.lower()
|
||||
@@ -947,6 +1091,13 @@ async def process_photo(
|
||||
status_code=415,
|
||||
detail=f"This format is disabled. Enabled format groups: {ENABLED_FORMAT_LABEL}.",
|
||||
)
|
||||
if rotation not in {0, 90, 180, 270}:
|
||||
raise HTTPException(status_code=422, detail="Rotation must be 0, 90, 180, or 270 degrees.")
|
||||
if rotation and suffix not in PIXEL_ROTATION_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Pixel rotation is not supported for {_format_name(suffix)} files.",
|
||||
)
|
||||
|
||||
exif_datetime, xmp_date, precision_label = _normalise_datetime(
|
||||
precision=precision,
|
||||
@@ -978,6 +1129,12 @@ async def process_photo(
|
||||
upload_started = time.perf_counter()
|
||||
await _save_upload(file, temp_path, suffix)
|
||||
upload_seconds = time.perf_counter() - upload_started
|
||||
rotation_mode = "none"
|
||||
rotation_seconds = 0.0
|
||||
if rotation:
|
||||
rotation_started = time.perf_counter()
|
||||
rotation_mode = _rotate_pixels(temp_path, suffix, rotation)
|
||||
rotation_seconds = time.perf_counter() - rotation_started
|
||||
|
||||
supports_iptc = suffix in IPTC_EXTENSIONS
|
||||
command = [
|
||||
@@ -999,6 +1156,8 @@ async def process_photo(
|
||||
]
|
||||
if supports_iptc:
|
||||
command.extend(["-IPTC:Caption-Abstract=", "-IPTC:Keywords="])
|
||||
if rotation:
|
||||
command.extend(["-EXIF:Orientation=1", "-XMP-tiff:Orientation=1"])
|
||||
|
||||
clean_description = description.strip()
|
||||
if clean_description:
|
||||
@@ -1066,9 +1225,11 @@ async def process_photo(
|
||||
updated_bytes = temp_path.read_bytes()
|
||||
response_read_seconds = time.perf_counter() - response_started
|
||||
logger.info(
|
||||
"Processed %s: upload %.2fs, ExifTool %.2fs, response read %.2fs",
|
||||
"Processed %s: upload %.2fs, rotation %.2fs (%s), ExifTool %.2fs, response read %.2fs",
|
||||
filename,
|
||||
upload_seconds,
|
||||
rotation_seconds,
|
||||
rotation_mode,
|
||||
exiftool_seconds,
|
||||
response_read_seconds,
|
||||
)
|
||||
@@ -1086,8 +1247,10 @@ async def process_photo(
|
||||
"Content-Length": str(len(updated_bytes)),
|
||||
"Cache-Control": "no-store",
|
||||
"X-Date-Precision": precision_label,
|
||||
"X-Pixel-Rotation": rotation_mode,
|
||||
"Server-Timing": (
|
||||
f'upload;dur={upload_seconds * 1000:.1f}, '
|
||||
f'rotation;dur={rotation_seconds * 1000:.1f}, '
|
||||
f'exiftool;dur={exiftool_seconds * 1000:.1f}, '
|
||||
f'read;dur={response_read_seconds * 1000:.1f}'
|
||||
),
|
||||
|
||||
+56
-7
@@ -40,6 +40,7 @@ const elements = {
|
||||
viewerEmpty: $('#viewer-empty'),
|
||||
viewerContent: $('#viewer-content'),
|
||||
preview: $('#photo-preview'),
|
||||
rotationStatus: $('#rotation-status'),
|
||||
filename: $('#current-filename'),
|
||||
filesize: $('#current-filesize'),
|
||||
position: $('#viewer-position'),
|
||||
@@ -208,6 +209,7 @@ async function scanFolder() {
|
||||
fileValues: null,
|
||||
metadataLoaded: false,
|
||||
metadataPresent: false,
|
||||
pendingRotation: 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -447,7 +449,9 @@ async function selectPhoto(index, force = false) {
|
||||
elements.filename.textContent = photo.name;
|
||||
elements.filesize.textContent = `${photo.format} · ${formatBytes(file.size)} · modified ${new Date(file.lastModified).toLocaleString()}`;
|
||||
elements.position.textContent = `${index + 1} / ${state.photos.length}`;
|
||||
resetView();
|
||||
state.zoom = 1;
|
||||
state.rotation = normalizeRotation(photo.pendingRotation || 0);
|
||||
applyView();
|
||||
restoreValues(photo.values);
|
||||
updatePhotoStatus(photo);
|
||||
updateNavigation();
|
||||
@@ -626,9 +630,11 @@ async function savePhoto(moveNext) {
|
||||
payload.append('longitude', values.longitude);
|
||||
payload.append('location_name', values.locationName);
|
||||
}
|
||||
const rotationToSave = supportsPixelRotation(photo) ? normalizeRotation(state.rotation) : 0;
|
||||
payload.append('rotation', String(rotationToSave));
|
||||
|
||||
const saveStarted = performance.now();
|
||||
setSaveStatus('saving', 'Uploading and rewriting metadata…', `${photo.name} · large RAW files can take a while`);
|
||||
setSaveStatus('saving', 'Uploading and applying changes…', `${photo.name} · large files can take a while`);
|
||||
const response = await fetch('/api/process', { method: 'POST', body: payload });
|
||||
if (!response.ok) {
|
||||
let message = `Save failed (${response.status}).`;
|
||||
@@ -637,6 +643,7 @@ async function savePhoto(moveNext) {
|
||||
}
|
||||
|
||||
const responseReceivedAt = performance.now();
|
||||
const rotationMode = response.headers.get('x-pixel-rotation') || 'none';
|
||||
const firstByteSeconds = (responseReceivedAt - saveStarted) / 1000;
|
||||
const writable = await photo.handle.createWritable({ keepExistingData: false });
|
||||
let timing;
|
||||
@@ -700,6 +707,8 @@ async function savePhoto(moveNext) {
|
||||
photo.fileValues = { ...savedValues };
|
||||
photo.metadataLoaded = true;
|
||||
photo.metadataPresent = true;
|
||||
photo.pendingRotation = 0;
|
||||
state.rotation = 0;
|
||||
photo.lastSaveTiming = timing;
|
||||
saveFolderState();
|
||||
setSaveStatus(
|
||||
@@ -707,7 +716,10 @@ async function savePhoto(moveNext) {
|
||||
'Saved in place',
|
||||
`${formatMetadataSummary(savedValues)} · ${formatSaveTiming(timing)}`,
|
||||
);
|
||||
showToast(`Saved ${photo.name}: ${formatSaveTiming(timing)}`);
|
||||
const rotationNote = rotationMode === 'jpeg-reencoded'
|
||||
? ' · rotation applied; JPEG re-encoded at high quality'
|
||||
: rotationMode !== 'none' ? ' · pixel rotation applied' : '';
|
||||
showToast(`Saved ${photo.name}: ${formatSaveTiming(timing)}${rotationNote}`);
|
||||
updateProgress();
|
||||
|
||||
const oldThumb = state.thumbUrls.get(photo.name);
|
||||
@@ -1075,8 +1087,45 @@ async function searchAddress() {
|
||||
|
||||
function applyView() {
|
||||
elements.preview.style.transform = `scale(${state.zoom}) rotate(${state.rotation}deg)`;
|
||||
updateRotationStatus();
|
||||
}
|
||||
|
||||
function normalizeRotation(rotation) {
|
||||
return ((Number(rotation) % 360) + 360) % 360;
|
||||
}
|
||||
|
||||
function supportsPixelRotation(photo) {
|
||||
return Boolean(photo && ['JPG', 'PNG', 'TIFF'].includes(photo.format));
|
||||
}
|
||||
|
||||
function updateRotationStatus() {
|
||||
const rotation = normalizeRotation(state.rotation);
|
||||
const photo = state.photos[state.currentIndex];
|
||||
elements.rotationStatus.classList.toggle('hidden', rotation === 0 || !photo);
|
||||
if (!rotation || !photo) return;
|
||||
const supported = supportsPixelRotation(photo);
|
||||
elements.rotationStatus.classList.toggle('preview-only', !supported);
|
||||
elements.rotationStatus.textContent = supported
|
||||
? `Rotation ${rotation}° will be applied to the image when saved`
|
||||
: `Preview rotation ${rotation}° · ${photo.format} files are not altered`;
|
||||
}
|
||||
|
||||
function setPendingRotation(rotation) {
|
||||
state.rotation = normalizeRotation(rotation);
|
||||
const photo = state.photos[state.currentIndex];
|
||||
if (photo) photo.pendingRotation = state.rotation;
|
||||
applyView();
|
||||
}
|
||||
|
||||
function fitView() {
|
||||
state.zoom = 1;
|
||||
applyView();
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
state.zoom = 1;
|
||||
setPendingRotation(0);
|
||||
}
|
||||
function resetView() { state.zoom = 1; state.rotation = 0; applyView(); }
|
||||
|
||||
for (const button of elements.openButtons) button.addEventListener('click', openFolder);
|
||||
elements.rescan.addEventListener('click', scanFolder);
|
||||
@@ -1102,9 +1151,9 @@ for (const input of [elements.latitude, elements.longitude]) {
|
||||
elements.locationName.addEventListener('input', markLocationDirty);
|
||||
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.fit.addEventListener('click', fitView);
|
||||
elements.rotateLeft.addEventListener('click', () => setPendingRotation(state.rotation - 90));
|
||||
elements.rotateRight.addEventListener('click', () => setPendingRotation(state.rotation + 90));
|
||||
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(); });
|
||||
|
||||
@@ -94,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">
|
||||
@@ -106,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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE_NAME = 'photo-date-editor-v9-0';
|
||||
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) => {
|
||||
|
||||
+25
-9
@@ -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); }
|
||||
@@ -70,12 +70,14 @@ button { color: inherit; }
|
||||
.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; }
|
||||
@@ -111,8 +113,22 @@ 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 */
|
||||
|
||||
Reference in New Issue
Block a user