Add shared favorite location v0.4.2

This commit is contained in:
dmo committed 2026-07-23 09:54:25 +02:00
1 parent 9b7d302064
commit e250a83b6d
8 files changed
+286 -9

No files matched your search

+89 -1
View File
@@ -7,7 +7,9 @@ import os
import re
import subprocess
import tempfile
import threading
import time
import uuid
import urllib.error
import urllib.parse
import urllib.request
@@ -17,12 +19,13 @@ from pathlib import Path
from typing import Annotated, Any
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from pydantic import BaseModel, Field
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles
APP_NAME = os.getenv("APP_NAME", "Photo Date Editor")
APP_URL = os.getenv("APP_URL", "http://localhost:8080")
APP_VERSION = "0.4.1"
APP_VERSION = "0.4.2"
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()
@@ -59,6 +62,16 @@ logger = logging.getLogger("photo-date-editor")
BASE_DIR = Path(__file__).resolve().parent
STATIC_DIR = BASE_DIR / "static"
DATA_DIR = Path(os.getenv("DATA_DIR", "/data"))
FAVORITES_FILE = DATA_DIR / "favorite-locations.json"
_favorites_lock = threading.Lock()
class FavoriteLocationInput(BaseModel):
name: str = Field(min_length=1, max_length=120)
latitude: float = Field(ge=-90, le=90)
longitude: float = Field(ge=-180, le=180)
app = FastAPI(title=APP_NAME, docs_url=None, redoc_url=None)
@@ -83,6 +96,81 @@ def config() -> dict[str, str | int | float]:
}
def _load_favorites_unlocked() -> list[dict[str, Any]]:
if not FAVORITES_FILE.exists():
return []
try:
payload = json.loads(FAVORITES_FILE.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
logger.warning("Could not read favorite locations: %s", exc)
return []
raw = payload.get("favorites", []) if isinstance(payload, dict) else []
favorites: list[dict[str, Any]] = []
for item in raw if isinstance(raw, list) else []:
if not isinstance(item, dict):
continue
try:
latitude = float(item["latitude"])
longitude = float(item["longitude"])
name = str(item["name"]).strip()
favorite_id = str(item["id"]).strip()
except (KeyError, TypeError, ValueError):
continue
if not name or not favorite_id or not -90 <= latitude <= 90 or not -180 <= longitude <= 180:
continue
favorites.append({
"id": favorite_id,
"name": name,
"latitude": latitude,
"longitude": longitude,
})
return sorted(favorites, key=lambda item: item["name"].casefold())
def _write_favorites_unlocked(favorites: list[dict[str, Any]]) -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
payload = {"favorites": sorted(favorites, key=lambda item: item["name"].casefold())}
temporary = FAVORITES_FILE.with_suffix(".tmp")
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
temporary.replace(FAVORITES_FILE)
@app.get("/api/favorites")
def list_favorites() -> dict[str, list[dict[str, Any]]]:
with _favorites_lock:
return {"favorites": _load_favorites_unlocked()}
@app.post("/api/favorites", status_code=201)
def create_favorite(value: FavoriteLocationInput) -> dict[str, dict[str, Any]]:
name = " ".join(value.name.split())
if not name:
raise HTTPException(status_code=422, detail="Favorite name cannot be empty.")
with _favorites_lock:
favorites = _load_favorites_unlocked()
if any(item["name"].casefold() == name.casefold() for item in favorites):
raise HTTPException(status_code=409, detail="A favorite with that name already exists.")
favorite = {
"id": uuid.uuid4().hex,
"name": name,
"latitude": round(value.latitude, 6),
"longitude": round(value.longitude, 6),
}
favorites.append(favorite)
_write_favorites_unlocked(favorites)
return {"favorite": favorite}
@app.delete("/api/favorites/{favorite_id}", status_code=204)
def delete_favorite(favorite_id: str) -> Response:
with _favorites_lock:
favorites = _load_favorites_unlocked()
remaining = [item for item in favorites if item["id"] != favorite_id]
if len(remaining) == len(favorites):
raise HTTPException(status_code=404, detail="Favorite location not found.")
_write_favorites_unlocked(remaining)
return Response(status_code=204)
def _fetch_map_tile(z: int, x: int, y: int) -> tuple[bytes, str]:
tile_url = (
+128
View File
@@ -12,6 +12,7 @@ const state = {
map: null,
mapMarker: null,
locationDirty: false,
favoriteLocations: [],
};
const $ = (selector) => document.querySelector(selector);
@@ -68,6 +69,10 @@ const elements = {
longitude: $('#longitude-input'),
locationName: $('#location-name-input'),
clearLocation: $('#clear-location'),
favoriteSelect: $('#favorite-location-select'),
useFavorite: $('#use-favorite-location'),
saveFavorite: $('#save-favorite-location'),
deleteFavorite: $('#delete-favorite-location'),
saveStatus: $('#save-status'),
shortcutsButton: $('#shortcuts-button'),
shortcutsDialog: $('#shortcuts-dialog'),
@@ -134,6 +139,8 @@ function saveFolderState() {
}
async function openFolder() {
await loadFavoriteLocations();
if (!isSupported()) {
showToast('Use a current Chromium browser over HTTPS. Folder access is unavailable here.', true);
return;
@@ -740,6 +747,123 @@ function clearLocation() {
showToast('Location cleared. Save the photo to remove GPS metadata.');
}
function renderFavoriteLocations(selectedId = '') {
const favorites = state.favoriteLocations;
elements.favoriteSelect.innerHTML = '';
if (!favorites.length) {
const option = document.createElement('option');
option.value = '';
option.textContent = 'No favorites saved';
elements.favoriteSelect.appendChild(option);
elements.favoriteSelect.disabled = true;
elements.useFavorite.disabled = true;
elements.deleteFavorite.disabled = true;
return;
}
const placeholder = document.createElement('option');
placeholder.value = '';
placeholder.textContent = 'Choose a favorite…';
elements.favoriteSelect.appendChild(placeholder);
for (const favorite of favorites) {
const option = document.createElement('option');
option.value = favorite.id;
option.textContent = `${favorite.name} — ${Number(favorite.latitude).toFixed(5)}, ${Number(favorite.longitude).toFixed(5)}`;
elements.favoriteSelect.appendChild(option);
}
elements.favoriteSelect.disabled = false;
elements.favoriteSelect.value = favorites.some((item) => item.id === selectedId) ? selectedId : '';
updateFavoriteButtons();
}
function updateFavoriteButtons() {
const selected = Boolean(elements.favoriteSelect.value);
elements.useFavorite.disabled = !selected;
elements.deleteFavorite.disabled = !selected;
}
async function loadFavoriteLocations(selectedId = '') {
try {
const response = await fetch('/api/favorites', { cache: 'no-store' });
if (!response.ok) throw new Error(`Could not load favorites (${response.status}).`);
const data = await response.json();
state.favoriteLocations = Array.isArray(data.favorites) ? data.favorites : [];
renderFavoriteLocations(selectedId);
} catch (error) {
state.favoriteLocations = [];
renderFavoriteLocations();
showToast(error.message || 'Could not load favorite locations.', true);
}
}
function useSelectedFavorite() {
const favorite = state.favoriteLocations.find((item) => item.id === elements.favoriteSelect.value);
if (!favorite) return;
setLocation(favorite.latitude, favorite.longitude, favorite.name, true, true);
showToast(`Location set to ${favorite.name}.`);
}
async function saveCurrentAsFavorite() {
if (!validCoordinates(elements.latitude.value, elements.longitude.value)) {
showToast('Set a valid location before saving it as a favorite.', true);
return;
}
const suggested = elements.locationName.value.trim() || 'Favorite location';
const entered = window.prompt('Name this favorite location:', suggested);
if (entered === null) return;
const name = entered.trim();
if (!name) {
showToast('Favorite name cannot be empty.', true);
return;
}
elements.saveFavorite.disabled = true;
try {
const response = await fetch('/api/favorites', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
latitude: Number(elements.latitude.value),
longitude: Number(elements.longitude.value),
}),
});
if (!response.ok) {
let message = `Could not save favorite (${response.status}).`;
try { message = (await response.json()).detail || message; } catch {}
throw new Error(message);
}
const data = await response.json();
await loadFavoriteLocations(data.favorite?.id || '');
showToast(`${name} added to favorite locations.`);
} catch (error) {
showToast(error.message || 'Could not save favorite location.', true);
} finally {
elements.saveFavorite.disabled = false;
}
}
async function deleteSelectedFavorite() {
const favorite = state.favoriteLocations.find((item) => item.id === elements.favoriteSelect.value);
if (!favorite) return;
if (!window.confirm(`Delete favorite location “${favorite.name}”?`)) return;
elements.deleteFavorite.disabled = true;
try {
const response = await fetch(`/api/favorites/${encodeURIComponent(favorite.id)}`, { method: 'DELETE' });
if (!response.ok) {
let message = `Could not delete favorite (${response.status}).`;
try { message = (await response.json()).detail || message; } catch {}
throw new Error(message);
}
await loadFavoriteLocations();
showToast(`${favorite.name} removed from favorite locations.`);
} catch (error) {
showToast(error.message || 'Could not delete favorite location.', true);
updateFavoriteButtons();
}
}
async function searchAddress() {
const query = elements.addressSearch.value.trim();
if (query.length < 3) {
@@ -799,6 +923,10 @@ elements.copyPrevious.addEventListener('click', copyPreviousValues);
elements.addressSearchButton.addEventListener('click', searchAddress);
elements.addressSearch.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); searchAddress(); } });
elements.clearLocation.addEventListener('click', clearLocation);
elements.favoriteSelect.addEventListener('change', updateFavoriteButtons);
elements.useFavorite.addEventListener('click', useSelectedFavorite);
elements.saveFavorite.addEventListener('click', saveCurrentAsFavorite);
elements.deleteFavorite.addEventListener('click', deleteSelectedFavorite);
for (const input of [elements.latitude, elements.longitude]) {
input.addEventListener('input', () => { state.locationDirty = true; syncMapFromFields(false); });
input.addEventListener('change', () => syncMapFromFields(true));
+19
View File
@@ -197,6 +197,25 @@
</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>
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = 'photo-date-editor-v4-1';
const CACHE_NAME = 'photo-date-editor-v4-2';
const SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest', '/vendor/leaflet/leaflet.css', '/vendor/leaflet/leaflet.js'];
self.addEventListener('install', (event) => {
+16
View File
@@ -139,3 +139,19 @@ fieldset { border: 0; padding: 0; margin: 0 0 22px; } legend { font-weight: 650;
.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); }