Add metadata display and skipped state v0.2
This commit is contained in:
1 parent
8738b8c814
commit
e2f5c91138
8 files changed
+322
-40
No files matched your search
Binary file not shown.
+151
-3
@@ -3,11 +3,12 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse, Response
|
||||
@@ -15,6 +16,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
|
||||
APP_NAME = os.getenv("APP_NAME", "Photo Date Editor")
|
||||
APP_URL = os.getenv("APP_URL", "http://localhost:8080")
|
||||
APP_VERSION = "0.2.0"
|
||||
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()
|
||||
@@ -33,7 +35,7 @@ app = FastAPI(title=APP_NAME, docs_url=None, redoc_url=None)
|
||||
|
||||
@app.get("/api/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
return {"status": "ok", "version": APP_VERSION}
|
||||
|
||||
|
||||
@app.get("/api/config")
|
||||
@@ -41,6 +43,7 @@ def config() -> dict[str, str | int]:
|
||||
return {
|
||||
"appName": APP_NAME,
|
||||
"appUrl": APP_URL,
|
||||
"version": APP_VERSION,
|
||||
"maxUploadMb": MAX_UPLOAD_MB,
|
||||
}
|
||||
|
||||
@@ -113,6 +116,150 @@ async def _save_upload(upload: UploadFile, destination: Path) -> None:
|
||||
raise HTTPException(status_code=415, detail="The uploaded file is not a valid JPEG image.")
|
||||
|
||||
|
||||
def _first_value(metadata: dict[str, Any], *keys: str) -> Any:
|
||||
for key in keys:
|
||||
value = metadata.get(key)
|
||||
if value not in (None, "", []):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _as_text(value: Any) -> str:
|
||||
if isinstance(value, list):
|
||||
return str(value[0]).strip() if value else ""
|
||||
if isinstance(value, dict):
|
||||
for candidate in ("x-default", "en", "en-US"):
|
||||
if candidate in value:
|
||||
return str(value[candidate]).strip()
|
||||
return str(next(iter(value.values()), "")).strip()
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _as_keywords(*values: Any) -> list[str]:
|
||||
output: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for value in values:
|
||||
items = value if isinstance(value, list) else [value]
|
||||
for item in items:
|
||||
text = _as_text(item)
|
||||
if not text or text.casefold() in seen:
|
||||
continue
|
||||
seen.add(text.casefold())
|
||||
output.append(text)
|
||||
return output
|
||||
|
||||
|
||||
def _metadata_values(metadata: dict[str, Any]) -> tuple[bool, bool, dict[str, str]]:
|
||||
date_text = _as_text(
|
||||
_first_value(
|
||||
metadata,
|
||||
"DateTimeOriginal",
|
||||
"CreateDate",
|
||||
"DateCreated",
|
||||
"ModifyDate",
|
||||
)
|
||||
)
|
||||
instructions = _as_text(_first_value(metadata, "Instructions"))
|
||||
description = _as_text(
|
||||
_first_value(
|
||||
metadata,
|
||||
"ImageDescription",
|
||||
"Description",
|
||||
"Caption-Abstract",
|
||||
)
|
||||
)
|
||||
keywords = _as_keywords(metadata.get("Subject"), metadata.get("Keywords"))
|
||||
|
||||
precision = "exact"
|
||||
time_mode = "known"
|
||||
marker = re.search(
|
||||
r"Photo Date Editor precision:\s*(Exact date|Month and year|Year only|Approximate year)"
|
||||
r"(?:;\s*time:\s*(Known|Unknown))?",
|
||||
instructions,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if marker:
|
||||
precision = {
|
||||
"exact date": "exact",
|
||||
"month and year": "month",
|
||||
"year only": "year",
|
||||
"approximate year": "approximate",
|
||||
}[marker.group(1).casefold()]
|
||||
if marker.group(2):
|
||||
time_mode = "known" if marker.group(2).casefold() == "known" else "unknown"
|
||||
|
||||
parsed_date: datetime | None = None
|
||||
for pattern in ("%Y:%m:%d %H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y:%m:%d", "%Y-%m-%d"):
|
||||
try:
|
||||
parsed_date = datetime.strptime(date_text, pattern)
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if marker and not marker.group(2) and parsed_date and parsed_date.strftime("%H:%M:%S") == "12:00:00":
|
||||
# Backward compatibility with v0.1, which used noon for unknown time but did not store a marker.
|
||||
time_mode = "unknown"
|
||||
|
||||
values = {
|
||||
"precision": precision,
|
||||
"year": str(parsed_date.year) if parsed_date else "",
|
||||
"month": str(parsed_date.month) if parsed_date else "1",
|
||||
"day": str(parsed_date.day) if parsed_date else "1",
|
||||
"timeMode": time_mode,
|
||||
"time": parsed_date.strftime("%H:%M:%S") if parsed_date else "12:00:00",
|
||||
"description": description,
|
||||
"keywords": ", ".join(keywords),
|
||||
}
|
||||
has_metadata = bool(parsed_date or description or keywords or marker)
|
||||
edited_by_app = bool(marker)
|
||||
return has_metadata, edited_by_app, values
|
||||
|
||||
|
||||
@app.post("/api/metadata")
|
||||
async def read_metadata(file: Annotated[UploadFile, File(...)]) -> dict[str, bool | dict[str, str]]:
|
||||
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.")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="photo-date-editor-read-") as temp_dir:
|
||||
temp_path = Path(temp_dir) / f"source{suffix}"
|
||||
await _save_upload(file, temp_path)
|
||||
completed = subprocess.run(
|
||||
[
|
||||
"exiftool",
|
||||
"-json",
|
||||
"-s",
|
||||
"-DateTimeOriginal",
|
||||
"-CreateDate",
|
||||
"-ModifyDate",
|
||||
"-DateCreated",
|
||||
"-Instructions",
|
||||
"-ImageDescription",
|
||||
"-Description",
|
||||
"-Caption-Abstract",
|
||||
"-Subject",
|
||||
"-Keywords",
|
||||
str(temp_path),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=45,
|
||||
check=False,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
logger.error("ExifTool metadata read failed: %s", completed.stderr.strip())
|
||||
raise HTTPException(status_code=500, detail="ExifTool could not read this image's metadata.")
|
||||
try:
|
||||
output = json.loads(completed.stdout)
|
||||
metadata = output[0] if isinstance(output, list) and output else {}
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(status_code=500, detail="ExifTool returned invalid metadata output.") from exc
|
||||
|
||||
has_metadata, edited_by_app, values = _metadata_values(metadata)
|
||||
return {"hasMetadata": has_metadata, "editedByApp": edited_by_app, "values": values}
|
||||
|
||||
|
||||
@app.post("/api/process")
|
||||
async def process_photo(
|
||||
file: Annotated[UploadFile, File(...)],
|
||||
@@ -136,6 +283,7 @@ async def process_photo(
|
||||
day=day,
|
||||
time_value=time_value,
|
||||
)
|
||||
time_label = "Known" if time_value else "Unknown"
|
||||
|
||||
try:
|
||||
raw_keywords = json.loads(keywords_json)
|
||||
@@ -160,7 +308,7 @@ async def process_photo(
|
||||
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}",
|
||||
f"-XMP-photoshop:Instructions=Photo Date Editor precision: {precision_label}; time: {time_label}",
|
||||
"-EXIF:ImageDescription=",
|
||||
"-XMP-dc:Description=",
|
||||
"-IPTC:Caption-Abstract=",
|
||||
|
||||
+124
-20
@@ -6,8 +6,8 @@ const state = {
|
||||
thumbUrls: new Map(),
|
||||
zoom: 1,
|
||||
rotation: 0,
|
||||
savedValues: new Map(),
|
||||
processing: false,
|
||||
selectionToken: 0,
|
||||
};
|
||||
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
@@ -110,6 +110,7 @@ function saveFolderState() {
|
||||
photoState[photo.name] = {
|
||||
status: photo.status,
|
||||
values: photo.values || null,
|
||||
lastModified: photo.lastModified,
|
||||
};
|
||||
}
|
||||
localStorage.setItem(key, JSON.stringify({ photos: photoState }));
|
||||
@@ -117,7 +118,7 @@ function saveFolderState() {
|
||||
|
||||
async function openFolder() {
|
||||
if (!isSupported()) {
|
||||
showToast('Use current Chrome or Edge over HTTPS. Folder access is unavailable here.', true);
|
||||
showToast('Use a current Chromium browser over HTTPS. Folder access is unavailable here.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -142,19 +143,23 @@ async function scanFolder() {
|
||||
if (handle.kind !== 'file' || !/\.(jpe?g)$/i.test(name)) continue;
|
||||
const file = await handle.getFile();
|
||||
const previous = stored.photos?.[name] || {};
|
||||
const unchanged = !previous.lastModified || previous.lastModified === file.lastModified;
|
||||
photos.push({
|
||||
name,
|
||||
handle,
|
||||
size: file.size,
|
||||
lastModified: file.lastModified,
|
||||
status: previous.status || 'pending',
|
||||
values: previous.values || null,
|
||||
status: unchanged ? (previous.status || 'pending') : 'pending',
|
||||
values: unchanged ? (previous.values || null) : null,
|
||||
fileValues: null,
|
||||
metadataLoaded: false,
|
||||
metadataPresent: false,
|
||||
});
|
||||
}
|
||||
|
||||
photos.sort((a, b) => naturalCompare(a.name, b.name));
|
||||
state.photos = photos;
|
||||
state.currentIndex = photos.length ? 0 : -1;
|
||||
state.currentIndex = -1;
|
||||
elements.folderName.textContent = state.directoryHandle.name;
|
||||
elements.folderAccess.textContent = 'Read/write access granted';
|
||||
elements.filter.disabled = !photos.length;
|
||||
@@ -190,6 +195,13 @@ async function getThumbUrl(photo) {
|
||||
return url;
|
||||
}
|
||||
|
||||
function statusSymbol(status) {
|
||||
if (status === 'saved') return '✓';
|
||||
if (status === 'skipped') return '→';
|
||||
if (status === 'failed') return '!';
|
||||
return '';
|
||||
}
|
||||
|
||||
function renderFileList() {
|
||||
const filter = elements.filter.value.trim().toLowerCase();
|
||||
const matching = state.photos
|
||||
@@ -207,11 +219,10 @@ function renderFileList() {
|
||||
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>`;
|
||||
<span class="file-state ${photo.status}" title="${photo.status}">${statusSymbol(photo.status)}</span>`;
|
||||
row.addEventListener('click', () => selectPhoto(index));
|
||||
elements.fileList.appendChild(row);
|
||||
getThumbUrl(photo).then((url) => {
|
||||
@@ -225,10 +236,69 @@ function renderFileList() {
|
||||
}
|
||||
}
|
||||
|
||||
function formatMetadataSummary(values) {
|
||||
if (!values?.year) return 'No date metadata has been entered.';
|
||||
const monthNames = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
];
|
||||
const month = monthNames[Math.max(0, Number(values.month || 1) - 1)];
|
||||
let dateText;
|
||||
if (values.precision === 'approximate') dateText = `Approx. ${values.year}`;
|
||||
else if (values.precision === 'year') dateText = values.year;
|
||||
else if (values.precision === 'month') dateText = `${month} ${values.year}`;
|
||||
else dateText = `${values.day} ${month} ${values.year}`;
|
||||
|
||||
const parts = [dateText, values.timeMode === 'known' ? values.time : 'time unknown'];
|
||||
if (values.description?.trim()) parts.push('description set');
|
||||
const keywordCount = parseKeywords(values.keywords || '').length;
|
||||
if (keywordCount) parts.push(`${keywordCount} keyword${keywordCount === 1 ? '' : 's'}`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function updatePhotoStatus(photo) {
|
||||
const values = photo.fileValues || photo.values;
|
||||
if (photo.status === 'saved') {
|
||||
setSaveStatus('saved', 'Previously saved', formatMetadataSummary(values));
|
||||
} else if (photo.status === 'skipped') {
|
||||
setSaveStatus('skipped', 'Previously skipped', 'No metadata was written to this file.');
|
||||
} else if (photo.status === 'failed') {
|
||||
setSaveStatus('failed', 'Previous save failed', photo.name);
|
||||
} else if (photo.metadataPresent) {
|
||||
setSaveStatus('idle', 'Metadata loaded from file', formatMetadataSummary(values));
|
||||
} else {
|
||||
setSaveStatus('idle', 'Ready to edit', photo.name);
|
||||
}
|
||||
}
|
||||
|
||||
async function readPhotoMetadata(photo, file) {
|
||||
if (photo.metadataLoaded) return;
|
||||
const payload = new FormData();
|
||||
payload.append('file', file, photo.name);
|
||||
const response = await fetch('/api/metadata', { method: 'POST', body: payload });
|
||||
if (!response.ok) {
|
||||
let message = `Metadata read failed (${response.status}).`;
|
||||
try { message = (await response.json()).detail || message; } catch {}
|
||||
throw new Error(message);
|
||||
}
|
||||
const data = await response.json();
|
||||
photo.metadataLoaded = true;
|
||||
photo.metadataPresent = Boolean(data.hasMetadata);
|
||||
photo.fileValues = data.hasMetadata ? data.values : null;
|
||||
if (!photo.values && data.hasMetadata) photo.values = { ...data.values };
|
||||
if (data.editedByApp && photo.status === 'pending') {
|
||||
photo.status = 'saved';
|
||||
saveFolderState();
|
||||
updateProgress();
|
||||
renderFileList();
|
||||
}
|
||||
}
|
||||
|
||||
async function selectPhoto(index, force = false) {
|
||||
if (index < 0 || index >= state.photos.length || (state.processing && !force)) return;
|
||||
captureCurrentValues();
|
||||
state.currentIndex = index;
|
||||
const selectionToken = ++state.selectionToken;
|
||||
const photo = state.photos[index];
|
||||
const file = await photo.handle.getFile();
|
||||
if (state.objectUrl) URL.revokeObjectURL(state.objectUrl);
|
||||
@@ -239,15 +309,25 @@ async function selectPhoto(index, force = false) {
|
||||
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,
|
||||
);
|
||||
updatePhotoStatus(photo);
|
||||
updateNavigation();
|
||||
renderFileList();
|
||||
const active = elements.fileList.querySelector('.file-row.active');
|
||||
active?.scrollIntoView({ block: 'nearest' });
|
||||
|
||||
if (!photo.metadataLoaded) {
|
||||
if (!photo.values) setSaveStatus('loading', 'Reading EXIF metadata…', photo.name);
|
||||
try {
|
||||
await readPhotoMetadata(photo, file);
|
||||
if (selectionToken !== state.selectionToken || state.currentIndex !== index) return;
|
||||
if (!formHasUserInput() && photo.values) restoreValues(photo.values);
|
||||
updatePhotoStatus(photo);
|
||||
} catch (error) {
|
||||
if (selectionToken === state.selectionToken && state.currentIndex === index) {
|
||||
setSaveStatus('failed', 'Could not read metadata', error.message || photo.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateNavigation() {
|
||||
@@ -282,6 +362,11 @@ function formValues() {
|
||||
};
|
||||
}
|
||||
|
||||
function formHasUserInput() {
|
||||
const values = formValues();
|
||||
return Boolean(values.year || values.description.trim() || values.keywords.trim());
|
||||
}
|
||||
|
||||
function captureCurrentValues() {
|
||||
if (state.currentIndex < 0 || !state.photos[state.currentIndex]) return;
|
||||
state.photos[state.currentIndex].values = formValues();
|
||||
@@ -367,8 +452,11 @@ async function savePhoto(moveNext) {
|
||||
photo.lastModified = updatedFile.lastModified;
|
||||
photo.status = 'saved';
|
||||
photo.values = values;
|
||||
photo.fileValues = { ...values };
|
||||
photo.metadataLoaded = true;
|
||||
photo.metadataPresent = true;
|
||||
saveFolderState();
|
||||
setSaveStatus('saved', 'Saved in place', `${photo.name} was overwritten successfully.`);
|
||||
setSaveStatus('saved', 'Saved in place', formatMetadataSummary(values));
|
||||
showToast(`Saved ${photo.name}`);
|
||||
updateProgress();
|
||||
|
||||
@@ -394,11 +482,27 @@ async function savePhoto(moveNext) {
|
||||
}
|
||||
}
|
||||
|
||||
function skipCurrentPhoto() {
|
||||
if (state.processing || state.currentIndex < 0) return;
|
||||
const photo = state.photos[state.currentIndex];
|
||||
captureCurrentValues();
|
||||
photo.status = 'skipped';
|
||||
saveFolderState();
|
||||
updateProgress();
|
||||
renderFileList();
|
||||
showToast(`Skipped ${photo.name}`);
|
||||
if (state.currentIndex < state.photos.length - 1) goRelative(1);
|
||||
else updatePhotoStatus(photo);
|
||||
}
|
||||
|
||||
function updateProgress() {
|
||||
const saved = state.photos.filter((photo) => photo.status === 'saved').length;
|
||||
const skipped = state.photos.filter((photo) => photo.status === 'skipped').length;
|
||||
const processed = saved + skipped;
|
||||
const total = state.photos.length;
|
||||
elements.progressCount.textContent = `${saved} / ${total}`;
|
||||
elements.progressBar.style.width = total ? `${(saved / total) * 100}%` : '0%';
|
||||
elements.progressCount.textContent = `${processed} / ${total}`;
|
||||
elements.progressBar.style.width = total ? `${(processed / total) * 100}%` : '0%';
|
||||
elements.progressCount.title = `${saved} saved, ${skipped} skipped`;
|
||||
}
|
||||
|
||||
function goRelative(delta) {
|
||||
@@ -409,7 +513,7 @@ function goRelative(delta) {
|
||||
function copyPreviousValues() {
|
||||
if (state.currentIndex <= 0) return;
|
||||
captureCurrentValues();
|
||||
const previous = state.photos[state.currentIndex - 1].values;
|
||||
const previous = state.photos[state.currentIndex - 1].values || state.photos[state.currentIndex - 1].fileValues;
|
||||
if (!previous) return showToast('The previous photo has no entered values yet.', true);
|
||||
restoreValues({ ...previous });
|
||||
captureCurrentValues();
|
||||
@@ -425,7 +529,7 @@ for (const button of elements.openButtons) button.addEventListener('click', open
|
||||
elements.rescan.addEventListener('click', scanFolder);
|
||||
elements.filter.addEventListener('input', renderFileList);
|
||||
elements.previous.addEventListener('click', () => goRelative(-1));
|
||||
elements.skip.addEventListener('click', () => goRelative(1));
|
||||
elements.skip.addEventListener('click', skipCurrentPhoto);
|
||||
elements.save.addEventListener('click', () => savePhoto(false));
|
||||
elements.saveNext.addEventListener('click', () => savePhoto(true));
|
||||
elements.copyPrevious.addEventListener('click', copyPreviousValues);
|
||||
@@ -464,7 +568,7 @@ window.addEventListener('keydown', (event) => {
|
||||
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() === 's') skipCurrentPhoto();
|
||||
else if (!typing && event.key.toLowerCase() === 'c') copyPreviousValues();
|
||||
});
|
||||
|
||||
@@ -479,8 +583,8 @@ async function initialise() {
|
||||
} 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';
|
||||
setSaveStatus('failed', 'Browser folder access unavailable', 'Use a current Chromium browser over HTTPS.');
|
||||
for (const button of elements.openButtons) button.title = 'Requires a Chromium browser and HTTPS';
|
||||
}
|
||||
|
||||
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/service-worker.js').catch(() => {});
|
||||
|
||||
@@ -31,7 +31,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>
|
||||
@@ -45,6 +45,7 @@
|
||||
<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 skipped"></i>Skipped</span>
|
||||
<span><i class="dot pending"></i>Pending</span>
|
||||
<span><i class="dot failed"></i>Failed</span>
|
||||
</div>
|
||||
@@ -96,7 +97,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 & Next →</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -192,9 +193,9 @@
|
||||
<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>
|
||||
</dl>
|
||||
</dialog>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE_NAME = 'photo-date-editor-v1';
|
||||
const CACHE_NAME = 'photo-date-editor-v2';
|
||||
const SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest'];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
|
||||
@@ -45,7 +45,7 @@ button { color: inherit; }
|
||||
.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); }
|
||||
.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-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; }
|
||||
@@ -58,6 +58,7 @@ button { color: inherit; }
|
||||
.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.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; }
|
||||
|
||||
@@ -88,7 +89,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; }
|
||||
|
||||
Reference in new issue
Block a user