From 8385453cfd80a18eac9dbeaa70f71f9102d97db1 Mon Sep 17 00:00:00 2001 From: Djeex Date: Wed, 23 Sep 2026 16:23:13 +0200 Subject: [PATCH] Add a photo permalink menu (#36) Right click or long press on a gallery photo now opens a menu to copy its permalink or share it. The permalink id is the SHA-256 of the original photo file so it survives reordering and rebuilds. Each photo also gets a small noindex share page carrying its own link preview image. Fixed the entrypoint script being checked out with CRLF on Windows by adding a gitattributes file. Reviewed-on: https://git.djeex.fr/Djeex/lumeex/pulls/36 --- .gitattributes | 2 + .gitignore | 1 + src/public/js/lumeex.js | 187 ++++++++++++++++++++++++++++++ src/public/style/style.css | 120 +++++++++++++++++++ src/py/builder/html_generator.py | 50 +++++++- src/py/builder/image_processor.py | 62 ++++++++++ src/py/builder/site_builder.py | 38 ++++++ tests/test_html_generator.py | 49 ++++++++ tests/test_image_processor.py | 76 ++++++++++++ tests/test_site_builder.py | 29 +++++ 10 files changed, 613 insertions(+), 1 deletion(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ff68b23 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Shell scripts run inside Linux containers: keep LF even on Windows checkouts +*.sh text eol=lf diff --git a/.gitignore b/.gitignore index b377941..d2f53fc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ !.env !.sh !.gitignore +!.gitattributes !.gitea !.gitea/** output/ diff --git a/src/public/js/lumeex.js b/src/public/js/lumeex.js index 918cbd6..1250e61 100644 --- a/src/public/js/lumeex.js +++ b/src/public/js/lumeex.js @@ -1,6 +1,10 @@ // js for Lumeex // https://git.djeex.fr/Djeex/lumeex +// The site is always served from the domain root (see the /img/ and +// /data/ paths below); the photo menu builds its permalinks from this. +const siteRoot = `${window.location.origin}/`; + // Fade in effect for elements with class 'appear' const setupIntersectionObserver = () => { document.querySelectorAll('.appear').forEach(parent => { @@ -163,6 +167,187 @@ const disableRightClickAndDrag = () => { document.addEventListener('dragstart', (e) => e.preventDefault()); }; +// Photo menu: right click (or long press on touch screens) on a gallery +// photo opens a small menu to copy its permalink or share it. The +// permalink points at photo//, a tiny page generated per photo that +// carries the photo's own preview tags for social networks and sends +// visitors back to the gallery with ?photo=. The id is a hash of the +// original photo file, so the link survives reordering and rebuilds. +const PHOTO_MENU_TEXT = { + en: { copy: 'Copy link', share: 'Share…', copied: 'Link copied', copyFailed: 'Could not copy the link' }, + fr: { copy: 'Copier le lien', share: 'Partager…', copied: 'Lien copié', copyFailed: 'Impossible de copier le lien' }, +}; + +const PHOTO_MENU_ICONS = { + copy: '', + share: '', +}; + +const setupPhotoMenu = () => { + const lang = (navigator.language || 'en').toLowerCase().startsWith('fr') ? 'fr' : 'en'; + const text = PHOTO_MENU_TEXT[lang]; + const canShare = typeof navigator.share === 'function'; + let menu = null; + let toast = null; + let toastTimer = null; + let currentUrl = ''; + let pressTimer = null; + let pressStart = null; + let openScrollY = 0; + + const photoAt = (el) => el.closest && el.closest('.section[data-photo-id] img'); + const permalinkFor = (img) => `${siteRoot}photo/${img.closest('.section').dataset.photoId}/`; + + const showToast = (message) => { + if (!toast) { + toast = document.createElement('div'); + toast.className = 'photo-toast'; + toast.setAttribute('role', 'status'); + document.body.appendChild(toast); + } + toast.textContent = message; + toast.classList.add('visible'); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => toast.classList.remove('visible'), 2000); + }; + + const copyText = async (value) => { + try { + await navigator.clipboard.writeText(value); + return true; + } catch { + // Clipboard API missing (plain http) or refused: fall back to the + // legacy selection-based copy. + const area = document.createElement('textarea'); + area.value = value; + area.setAttribute('readonly', ''); + area.style.position = 'fixed'; + area.style.opacity = '0'; + document.body.appendChild(area); + area.select(); + let ok = false; + try { ok = document.execCommand('copy'); } catch { ok = false; } + area.remove(); + return ok; + } + }; + + const close = () => { + if (!menu || menu.hidden) return; + menu.hidden = true; + }; + + const buildMenu = () => { + menu = document.createElement('div'); + menu.className = 'photo-menu'; + menu.setAttribute('role', 'menu'); + menu.tabIndex = -1; + menu.hidden = true; + const actions = canShare ? ['copy', 'share'] : ['copy']; + actions.forEach((action) => { + const button = document.createElement('button'); + button.type = 'button'; + button.setAttribute('role', 'menuitem'); + button.dataset.action = action; + button.innerHTML = PHOTO_MENU_ICONS[action]; + button.append(text[action]); + menu.appendChild(button); + }); + menu.addEventListener('click', async (e) => { + const button = e.target.closest('button'); + if (!button) return; + const url = currentUrl; + close(); + if (button.dataset.action === 'copy') { + showToast((await copyText(url)) ? text.copied : text.copyFailed); + } else { + // AbortError just means the visitor dismissed the share sheet. + navigator.share({ title: document.title, url }).catch(() => {}); + } + }); + menu.addEventListener('keydown', (e) => { + if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return; + e.preventDefault(); + const items = Array.from(menu.querySelectorAll('button')); + const i = items.indexOf(document.activeElement); + const next = i === -1 + ? (e.key === 'ArrowDown' ? 0 : items.length - 1) + : (i + (e.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length; + items[next].focus(); + }); + document.body.appendChild(menu); + }; + + const open = (img, x, y) => { + if (!menu) buildMenu(); + currentUrl = permalinkFor(img); + openScrollY = window.scrollY; + menu.hidden = false; + // Keep the whole menu inside the viewport near the pointer. + const margin = 8; + const left = Math.min(x, window.innerWidth - menu.offsetWidth - margin); + const top = Math.min(y, window.innerHeight - menu.offsetHeight - margin); + menu.style.left = `${Math.max(margin, left)}px`; + menu.style.top = `${Math.max(margin, top)}px`; + // Focus the menu itself (not its first item) so Escape and the arrow + // keys work without a focus ring showing after a mouse right click. + menu.focus({ preventScroll: true }); + }; + + document.addEventListener('contextmenu', (e) => { + const img = photoAt(e.target); + if (img) open(img, e.clientX, e.clientY); + else close(); + }); + + // iOS never fires contextmenu on a long press, so time it by hand. + const cancelPress = () => { + clearTimeout(pressTimer); + pressTimer = null; + }; + document.addEventListener('pointerdown', (e) => { + if (menu && !menu.hidden && !menu.contains(e.target)) close(); + if (e.pointerType === 'mouse') return; + const img = photoAt(e.target); + if (!img) return; + pressStart = { x: e.clientX, y: e.clientY }; + cancelPress(); + pressTimer = setTimeout(() => open(img, pressStart.x, pressStart.y), 500); + }); + document.addEventListener('pointermove', (e) => { + if (pressTimer && Math.hypot(e.clientX - pressStart.x, e.clientY - pressStart.y) > 10) cancelPress(); + }); + ['pointerup', 'pointercancel'].forEach((type) => document.addEventListener(type, cancelPress)); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') close(); + }); + // Only a real scroll closes the menu: lazy-loaded photos shifting the + // layout also fire small scroll events (scroll anchoring). + window.addEventListener('scroll', () => { + if (Math.abs(window.scrollY - openScrollY) > 40) close(); + }, { passive: true }); + window.addEventListener('resize', close); + window.addEventListener('blur', close); +}; + +// Permalink landing: ?photo= moves that photo to the top of the +// gallery (after the shuffle) and scrolls to it. +const openLinkedPhoto = () => { + const id = new URLSearchParams(window.location.search).get('photo'); + if (!id) return; + const gallery = document.querySelector('#gallery'); + const section = gallery && Array.from(gallery.querySelectorAll('.section[data-photo-id]')) + .find((el) => el.dataset.photoId === id); + if (!section) return; + gallery.prepend(section); + section.classList.add('photo-linked'); + // Wait for the loader to fade out so the scroll lands on the final layout. + window.addEventListener('load', () => { + section.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); +}; + // Scroll-to-top button functionality const setupScrollToTopButton = () => { const scrollBtn = document.getElementById("scrollToTop"); @@ -190,9 +375,11 @@ document.addEventListener("DOMContentLoaded", () => { setupIntersectionObserver(); setupLoader(); shuffleGallery(); + openLinkedPhoto(); randomizeHeroBackground(); setupTagFilter(); disableRightClickAndDrag(); + setupPhotoMenu(); setupScrollToTopButton(); fixNavSeparators(); }); diff --git a/src/public/style/style.css b/src/public/style/style.css index 5cf76de..3ad5ae7 100644 --- a/src/public/style/style.css +++ b/src/public/style/style.css @@ -331,6 +331,126 @@ h2 { margin: 5px 0 60px 0; } +/* Photo menu (right click / long press on a gallery photo) */ + +.section[data-photo-id] img { + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; +} + +.photo-menu { + position: fixed; + z-index: 1100; + min-width: 200px; + padding: 6px; + background: var(--color-background); + border: 1px solid rgb(0 0 0 / 7%); + border-radius: 14px; + box-shadow: 0 12px 32px rgb(0 0 0 / 16%), 0 2px 6px rgb(0 0 0 / 6%); + font-family: var(--font-primary), Arial, sans-serif; + transform-origin: top left; + animation: photo-menu-in 0.14s ease-out; +} + +.photo-menu:focus { + outline: none; +} + +.photo-menu[hidden] { + display: none; +} + +.photo-menu button { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + box-sizing: border-box; + padding: 10px 12px; + border: none; + border-radius: 9px; + background: none; + font: inherit; + font-size: 15px; + color: var(--color-text-dark); + text-align: left; + cursor: pointer; +} + +.photo-menu button:hover, +.photo-menu button:focus-visible { + background: rgb(245 245 245); + color: var(--color-primary-dark); +} + +.photo-menu button:focus { + outline: none; +} + +.photo-menu button:focus-visible { + outline: 2px solid var(--color-secondary); + outline-offset: -2px; +} + +.photo-menu svg { + width: 18px; + height: 18px; + flex: none; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.photo-toast { + position: fixed; + left: 50%; + bottom: 24px; + z-index: 1100; + padding: 9px 18px; + border-radius: 30px; + background: var(--color-primary-dark); + color: var(--color-background); + font-family: var(--font-primary), Arial, sans-serif; + font-size: 14px; + white-space: nowrap; + box-shadow: 0 6px 18px rgb(0 0 0 / 18%); + opacity: 0; + transform: translate(-50%, 8px); + transition: opacity 0.2s ease, transform 0.2s ease; + pointer-events: none; +} + +.photo-toast.visible { + opacity: 1; + transform: translate(-50%, 0); +} + +.section.photo-linked img { + animation: photo-linked 2.4s ease-out 0.6s; +} + +@keyframes photo-menu-in { + from { opacity: 0; transform: scale(0.96); } + to { opacity: 1; transform: none; } +} + +@keyframes photo-linked { + 0%, 60% { box-shadow: 0 0 0 3px var(--color-background), 0 0 0 6px var(--color-secondary); } + 100% { box-shadow: 0 0 0 3px transparent, 0 0 0 6px transparent; } +} + +@media (prefers-reduced-motion: reduce) { + .photo-menu, .section.photo-linked img { + animation: none; + } + .photo-toast { + transition: none; + } +} + .text-block { padding:10px; margin:10px; diff --git a/src/py/builder/html_generator.py b/src/py/builder/html_generator.py index de704f5..695c49d 100644 --- a/src/py/builder/html_generator.py +++ b/src/py/builder/html_generator.py @@ -1,6 +1,8 @@ import json import logging +from html import escape from pathlib import Path +from urllib.parse import quote def render_template(template_path, context): @@ -19,8 +21,11 @@ def render_gallery_images(images): for img in images: tags = " ".join(img.get("tags", [])) tag_html = "".join(f'#{t}' for t in img.get("tags", [])) + # data-photo-id is what js/lumeex.js's photo menu builds the permalink from + photo_id = img.get("photo_id") + photo_id_attr = f' data-photo-id="{photo_id}"' if photo_id else "" html += f""" -
+
{tag_html}
{img.get(
@@ -83,3 +88,46 @@ def generate_sitemap_xml(canonical_url, allowed_paths, output_dir): with open(output_path, "w", encoding="utf-8") as f: f.write(sitemap_content) logging.info(f"[✓] sitemap.xml generated at {output_path}") + + +def render_photo_page( + photo_id, site_title, description, alt, page_url, image_url, image_size, signature +): + """ + photo//index.html: the page a copied/shared photo link points at. + Link-preview crawlers read its og:/twitter: tags (without running + scripts) and show this photo; visitors are sent on to the gallery with + ?photo=. Kept out of search results with noindex rather than + robots.txt, since preview crawlers such as Twitterbot honour robots.txt + and would then never read the tags. + """ + title = f"{alt} - {site_title}" if alt else site_title + gallery_href = f"/?photo={quote(photo_id)}" + width, height = image_size + t = escape(title) + return f""" +{signature} + + + + {t} + + + + + + + + + + + + + + + + + +

{t}

+ +""" diff --git a/src/py/builder/image_processor.py b/src/py/builder/image_processor.py index b73a223..f403522 100644 --- a/src/py/builder/image_processor.py +++ b/src/py/builder/image_processor.py @@ -1,9 +1,13 @@ +import hashlib +import io import logging from pathlib import Path from shutil import copyfile from PIL import Image, features +SHARE_PREVIEW_MAX_WIDTH = 1200 + def convert_and_resize_image(input_path, output_path, resize=True, max_width=1140): """Convert an image to WebP (or JPEG fallback) and optionally resize it.""" @@ -56,6 +60,64 @@ def process_images(images, resize_images, img_dir, build_dir): img["src"] = str(Path(img["src"]).with_suffix(".jpg")) +def compute_photo_ids(images, img_dir): + """ + Set each image's permalink id (img["photo_id"]): the SHA-256 of its + original file, so the same photo keeps the same link whatever its + position or filename, across rebuilds. lumeex-web computes the same + value. Duplicates of the same file get "-2", "-3"... in list order. + Must run before process_images, which rewrites img["src"]. + """ + seen = {} + for img in images: + src_path = img_dir / img["src"] + if not src_path.exists(): + continue + digest = hashlib.sha256(src_path.read_bytes()).hexdigest() + seen[digest] = seen.get(digest, 0) + 1 + img["photo_id"] = digest if seen[digest] == 1 else f"{digest}-{seen[digest]}" + + +def _to_srgb(img, icc_profile): + """Convert to sRGB when Pillow was built with LittleCMS; unchanged otherwise.""" + try: + from PIL import ImageCms + except ImportError: + return img + try: + src_profile = ImageCms.ImageCmsProfile(io.BytesIO(icc_profile)) + return ImageCms.profileToProfile(img, src_profile, ImageCms.createProfile("sRGB")) + except ImageCms.PyCMSError as e: + logging.warning(f"[~] Could not convert embedded profile to sRGB: {e}") + return img + + +def generate_share_preview(input_path, output_path, max_width=SHARE_PREVIEW_MAX_WIDTH): + """ + Link-preview image for one gallery photo (photo// pages): a plain + sRGB JPEG no wider than max_width, since WebP and wide-gamut profiles + aren't handled by every messaging app. Returns (width, height), or None + on failure. + """ + try: + img = Image.open(input_path) + icc_profile = img.info.get("icc_profile") + img = img.convert("RGB") + if icc_profile: + img = _to_srgb(img, icc_profile) + width, height = img.size + if width > max_width: + height = int((max_width / width) * height) + width = max_width + img = img.resize((width, height), Image.LANCZOS) + output_path.parent.mkdir(parents=True, exist_ok=True) + img.save(output_path, "JPEG", quality=85) + return width, height + except Exception as e: + logging.error(f"[✗] Error generating share preview for {input_path}: {e}") + return None + + def copy_original_images(images, img_dir, build_dir): """Copy original image files without processing.""" for img in images: diff --git a/src/py/builder/site_builder.py b/src/py/builder/site_builder.py index 58898c6..7d58ce0 100644 --- a/src/py/builder/site_builder.py +++ b/src/py/builder/site_builder.py @@ -11,12 +11,15 @@ from .html_generator import ( generate_robots_txt, generate_sitemap_xml, render_gallery_images, + render_photo_page, render_template, ) from .image_processor import ( + compute_photo_ids, copy_original_images, generate_favicon_ico, generate_favicons_from_logo, + generate_share_preview, process_images, ) from .utils import copy_assets, ensure_dir, load_theme_config, load_yaml @@ -91,6 +94,13 @@ def build(): hero_images = gallery_vars.get("hero", {}).get("images", []) gallery_images = gallery_vars.get("gallery", {}).get("images", []) + # Permalink ids for gallery photos (not the hero carousel), hashed from + # the original files — before process_images rewrites their src + compute_photo_ids(gallery_images, IMG_DIR) + original_src_by_photo_id = { + img["photo_id"]: img["src"] for img in gallery_images if "photo_id" in img + } + if convert_images: process_images(hero_images, resize_images, IMG_DIR, BUILD_DIR) process_images(gallery_images, resize_images, IMG_DIR, BUILD_DIR) @@ -191,6 +201,34 @@ def build(): else: logging.warning("[~] No legals section found in site.yaml") + # One link-preview page + preview image per gallery photo. og:image and + # og:url must be absolute for crawlers, hence the canonical URL. + site_info = site_vars.get("info", {}) + share_pages = 0 + for img in gallery_images: + photo_id = img.get("photo_id") + if not photo_id: + continue + preview_path = BUILD_DIR / "img" / "share" / f"{photo_id}.jpg" + size = generate_share_preview(IMG_DIR / original_src_by_photo_id[photo_id], preview_path) + if not size: + continue + page = render_photo_page( + photo_id, + site_title=site_info.get("title", ""), + description=site_info.get("description", ""), + alt=img.get("alt", ""), + page_url=f"{canonical_url}/photo/{photo_id}/", + image_url=f"{canonical_url}/img/share/{photo_id}.jpg", + image_size=size, + signature=signature, + ) + page_path = BUILD_DIR / "photo" / photo_id / "index.html" + page_path.parent.mkdir(parents=True, exist_ok=True) + page_path.write_text(page, encoding="utf-8") + share_pages += 1 + logging.info(f"[✓] Photo share pages generated: {share_pages}") + # Hero carrousel generator if hero_images: generate_gallery_json_from_images(hero_images, BUILD_DIR) diff --git a/tests/test_html_generator.py b/tests/test_html_generator.py index 908f5d8..52ed479 100644 --- a/tests/test_html_generator.py +++ b/tests/test_html_generator.py @@ -39,6 +39,55 @@ def test_render_gallery_images_with_and_without_tags(): assert 'alt=""' in html +def test_render_gallery_images_adds_photo_id_when_present(): + html = hg.render_gallery_images( + [{"src": "gallery/a.jpg", "photo_id": "abc123"}, {"src": "gallery/b.jpg"}] + ) + + assert html.count("data-photo-id=") == 1 + assert 'data-photo-id="abc123"' in html + + +def _photo_page(**overrides): + kwargs = { + "photo_id": "ab12", + "site_title": "My photos", + "description": "A gallery", + "alt": "Sunset", + "page_url": "https://example.com/photo/ab12/", + "image_url": "https://example.com/img/share/ab12.jpg", + "image_size": (1200, 800), + "signature": "", + } + kwargs.update(overrides) + return hg.render_photo_page(**kwargs) + + +def test_render_photo_page_preview_tags_and_noindex(): + page = _photo_page() + + assert '' in page + assert '' in page + assert '' in page + assert '' in page + assert "Sunset - My photos" in page + assert 'rel="canonical"' not in page + + +def test_render_photo_page_redirects_to_gallery(): + page = _photo_page() + + assert 'location.replace("/?photo=ab12")' in page + assert '' in page + + +def test_render_photo_page_escapes_text(): + page = _photo_page(alt="", site_title='">') + + assert "