diff --git a/src/offlickr/fetch/runner.py b/src/offlickr/fetch/runner.py
index 15e1a26..4f315c8 100644
--- a/src/offlickr/fetch/runner.py
+++ b/src/offlickr/fetch/runner.py
@@ -44,7 +44,12 @@ def _thumb_progress() -> None:
for fave in model.get("faves", []):
pid = fave["photo_id"]
if pid in thumb_map:
- fave["thumbnail_path"] = thumb_map[pid]
+ path, w, h = thumb_map[pid]
+ fave["thumbnail_path"] = path
+ if w:
+ fave["thumbnail_width"] = w
+ if h:
+ fave["thumbnail_height"] = h
if include_avatars:
nsids = list(model.get("users", {}).keys())
diff --git a/src/offlickr/fetch/thumbnails.py b/src/offlickr/fetch/thumbnails.py
index 6087335..4510079 100644
--- a/src/offlickr/fetch/thumbnails.py
+++ b/src/offlickr/fetch/thumbnails.py
@@ -6,18 +6,39 @@
from pathlib import Path
from typing import Any
+from PIL import Image
+
from offlickr.fetch.client import FlickrClient
from offlickr.issues import IssueCollector
-_PREFERRED = ("Large Square", "Square", "Small")
+# Prefer sizes that preserve natural aspect ratios; fall back to square crops.
+_PREFERRED = ("Small 320", "Small", "Medium 640", "Medium", "Large Square", "Square")
-def pick_thumb_url(sizes: list[dict[str, Any]]) -> str | None:
- by_label: dict[str, str] = {str(s["label"]): str(s["source"]) for s in sizes}
+def pick_thumb(sizes: list[dict[str, Any]]) -> tuple[str, int, int] | None:
+ """Return (url, width, height) for the best available size, or None."""
+ by_label: dict[str, dict[str, Any]] = {str(s["label"]): s for s in sizes}
for label in _PREFERRED:
if label in by_label:
- return by_label[label]
- return str(sizes[0]["source"]) if sizes else None
+ s = by_label[label]
+ return str(s["source"]), int(s.get("width", 0)), int(s.get("height", 0))
+ if sizes:
+ s = sizes[0]
+ return str(s["source"]), int(s.get("width", 0)), int(s.get("height", 0))
+ return None
+
+
+def pick_thumb_url(sizes: list[dict[str, Any]]) -> str | None:
+ result = pick_thumb(sizes)
+ return result[0] if result else None
+
+
+def _read_image_size(path: Path) -> tuple[int, int]:
+ try:
+ with Image.open(path) as img:
+ return img.size
+ except Exception:
+ return 0, 0
def fetch_fave_thumbnails(
@@ -27,27 +48,28 @@ def fetch_fave_thumbnails(
*,
on_progress: Callable[[], None] | None = None,
collector: IssueCollector | None = None,
-) -> dict[str, str]:
- """Return {photo_id: relative_path} for successfully downloaded thumbnails."""
+) -> dict[str, tuple[str, int, int]]:
+ """Return {photo_id: (relative_path, width, height)} for downloaded thumbnails."""
thumbs_dir = output_dir / "fave-thumbs"
thumbs_dir.mkdir(parents=True, exist_ok=True)
- result: dict[str, str] = {}
+ result: dict[str, tuple[str, int, int]] = {}
for photo_id in photo_ids:
dest = thumbs_dir / f"{photo_id}.jpg"
rel = f"fave-thumbs/{photo_id}.jpg"
if dest.exists():
- result[photo_id] = rel
+ result[photo_id] = (rel, *_read_image_size(dest))
if on_progress:
on_progress()
continue
try:
sizes = client.get_photo_sizes(photo_id)
- url = pick_thumb_url(sizes)
- if url:
+ picked = pick_thumb(sizes)
+ if picked:
+ url, w, h = picked
tmp = dest.with_suffix(".tmp")
tmp.write_bytes(client.download(url))
tmp.rename(dest)
- result[photo_id] = rel
+ result[photo_id] = (rel, w, h)
except Exception as exc:
dest.with_suffix(".tmp").unlink(missing_ok=True)
if collector:
diff --git a/src/offlickr/model.py b/src/offlickr/model.py
index ccc0c95..e81a6ef 100644
--- a/src/offlickr/model.py
+++ b/src/offlickr/model.py
@@ -354,6 +354,8 @@ class Fave(BaseModel):
photo_url_flickr: str | None = None
owner_nsid: str | None = None
thumbnail_path: str | None = None
+ thumbnail_width: int | None = None
+ thumbnail_height: int | None = None
@classmethod
def from_json(cls, data: dict[str, Any]) -> Fave:
diff --git a/src/offlickr/themes/minimal-archive/static/style.css b/src/offlickr/themes/minimal-archive/static/style.css
index af1a891..5e8384d 100644
--- a/src/offlickr/themes/minimal-archive/static/style.css
+++ b/src/offlickr/themes/minimal-archive/static/style.css
@@ -17,7 +17,7 @@
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
Arial, "Noto Sans", "Noto Sans Hebrew", sans-serif;
--font-serif: Georgia, "Times New Roman", "Noto Serif", serif;
- --thumb-h: 180px;
+ --thumb-h: 220px;
}
body { font-family: var(--font-ui); background: var(--bg); color: var(--fg); line-height: 1.6; }
@@ -58,6 +58,7 @@ a:hover { text-decoration: underline; }
/* Main content */
main { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
+main.main--wide { max-width: none; padding: .75rem; }
.page-title { font-family: var(--font-serif); font-size: 1.6rem; margin-bottom: 1rem; }
/* Photostream grid — justified layout: tiles grow to fill each row */
@@ -73,7 +74,7 @@ main { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
background: var(--bg-muted); line-height: 0;
flex-grow: 1; flex-shrink: 1;
height: var(--thumb-h);
- max-width: calc(var(--thumb-h) * 4);
+ max-width: calc(var(--thumb-h) * 6);
}
.grid-tile img {
display: block; width: 100%; height: 100%;
@@ -259,7 +260,8 @@ a.external-link::after { content: " ↗"; font-size: .75em; }
.map-subtitle { color: var(--mid); margin-bottom: .5rem; }
@media (max-width: 600px) {
- :root { --thumb-h: 120px; }
+ :root { --thumb-h: 130px; }
+ main.main--wide { padding: .4rem; }
.site-nav { gap: .75rem; }
#search-input { width: 140px; }
.photo-detail h1 { font-size: 1.4rem; }
diff --git a/src/offlickr/themes/minimal-archive/templates/base.html.j2 b/src/offlickr/themes/minimal-archive/templates/base.html.j2
index ee48fc5..cb6ceeb 100644
--- a/src/offlickr/themes/minimal-archive/templates/base.html.j2
+++ b/src/offlickr/themes/minimal-archive/templates/base.html.j2
@@ -27,7 +27,8 @@
{% block photo_hero %}{% endblock %}
-