Files
imagesbyolofsson-se/src/pages/portfolio.astro
T
Linus OlofssonandClaude Opus 5.5 1e921dfe37 fix(images): stop publishing EXIF/GPS originals, responsive images, crawlable gallery
- Homepage images: <Picture> from src/assets (AVIF/WebP/JPG srcset, width/
  height, first image eager + fetchpriority high, rest lazy), Swedish alt
  texts. (The duplicate EXIF-laden copies in public/3d-scroll were deleted
  in 30e385e; README links now point at src/assets.)
- Portfolio: originals are no longer referenced (no .src access), so Astro
  does not copy them to dist/. Small/Medium/Large downloads and the lightbox
  srcset are EXIF-free JPGs from getImage. Old -s/-m files are no longer
  imported.
- Alt texts: Swedish fallback from date (EXIF or filename), never camera
  model or "undefined"; portfolio-meta.ts overrides per image.
- Gallery items are <a href> to the large image (lightbox on click);
  download options are buttons; no empty H3, no H4 for the password box.
- Stats beacon unchanged; item keys computed at build time (same format).
- Script moved inside the layout so it is no longer rendered after </html>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WvE3C43Uwtv2vtnoFBLDw7
2026-10-27 19:13:24 +01:00

571 lines
29 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
// src/pages/portfolio.astro
import MainLayout from '../layouts/MainLayout.astro';
import { Image, getImage } from 'astro:assets';
import exifr from 'exifr';
import fs from 'node:fs';
import path from 'node:path';
import WaveBackground from '../components/WaveBackground.astro';
import { business, genreList } from '../data/business';
import { portfolioMeta } from '../data/portfolio-meta';
// SERVER-SIDE LOGIC (byggtid)
// 1. Hämta alla galleribilder.
// -s (800px) och -m (1600px) i mappen är gamla nedladdningsvarianter med EXIF kvar.
// De importeras INTE längre (då hamnar de inte i dist/); nedladdningsstorlekarna
// tas i stället fram av Astros bildtjänst nedan, vilket också rensar bort EXIF
// (GPS, kamerans serienummer, Artist m.m.).
const imageFiles = import.meta.glob(
[
'../assets/images/portfolio/*.{jpeg,jpg,png,webp,JPG,JPEG,PNG}',
'!../assets/images/portfolio/*-{s,m}.{jpeg,jpg,png,webp,JPG,JPEG,PNG}',
],
{ eager: true }
);
// OBS: läs aldrig .src/.width/.height direkt på de importerade bilderna här.
// Då räknar Astro originalet som "använt" och kopierar originalfilen (med EXIF)
// till dist/. Gå alltid via <Image>/getImage.
function formatShutterSpeed(exposureTime: number) {
if (!exposureTime) return null;
if (exposureTime >= 1) return exposureTime.toString();
const fraction = Math.round(1 / exposureTime);
return `1/${fraction}`;
}
// Datum ur filnamnet: "immich-<id>-20260905-..." eller "20250510-...".
function dateFromFilename(name: string): Date | null {
const m = name.match(/(?:^|-)((?:19|20)\d{2})(\d{2})(\d{2})-/);
if (!m) return null;
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
return isNaN(d.getTime()) ? null : d;
}
const widthOf = (descriptor: string) => parseInt(descriptor, 10) || 0;
const galleryItems = await Promise.all(Object.entries(imageFiles).map(async ([relativePath, file]: [string, any]) => {
let exifData: any = {};
let dateTaken: Date | null = null;
const absolutePath = path.resolve('./src/pages', relativePath);
const fileName = path.basename(relativePath);
// Stabil nyckel = filnamn utan ändelse. Används för alt-texter (portfolio-meta.ts) och statistik.
const key = fileName.replace(/\.[^.]+$/, '');
try {
const fileBuffer = fs.readFileSync(absolutePath);
exifData = await exifr.parse(fileBuffer, [
'ISO', 'Model', 'FNumber', 'ExposureTime', 'FocalLength', 'DateTimeOriginal', 'CreateDate'
]);
const exifDate = exifData?.DateTimeOriginal || exifData?.CreateDate;
if (exifDate) dateTaken = new Date(exifDate);
} catch (e) {
console.error(`Kunde inte läsa EXIF för ${relativePath}`);
}
if (!dateTaken || isNaN(dateTaken.getTime())) dateTaken = dateFromFilename(fileName);
const hasDate = !!dateTaken;
const dateObj = dateTaken ?? new Date();
const year = dateObj.getFullYear().toString();
const month = dateObj.toLocaleString('sv-SE', { month: 'long' }).toLowerCase();
const monthIndex = dateObj.getMonth();
const fullDate = hasDate
? dateObj.toLocaleDateString('sv-SE', { day: 'numeric', month: 'long', year: 'numeric' })
: '';
const shutterSpeed = formatShutterSpeed(exifData?.ExposureTime);
const focalLength = exifData?.FocalLength ? `${Math.round(exifData.FocalLength)}mm` : null;
const fStop = exifData?.FNumber ? `f/${exifData.FNumber}` : null;
const iso = exifData?.ISO ? `ISO ${exifData.ISO}` : null;
// Alt-text: riktig text från portfolio-meta.ts om den finns, annars en neutral
// svensk reservtext. Aldrig kameramodell, aldrig "undefined".
const meta = portfolioMeta[key] ?? {};
const fallbackAlt = [
meta.category ? `${meta.category}:` : null,
`fotografi av ${business.person.name}`,
fullDate ? `taget ${fullDate}` : null,
].filter(Boolean).join(' ');
const alt = meta.alt?.trim() || fallbackAlt.charAt(0).toUpperCase() + fallbackAlt.slice(1);
// Nedladdning/lightbox: nya, EXIF-fria JPG:er i 800/1600/3000 px
// (Astro hoppar över bredder större än originalet).
const jpg = await getImage({ src: file.default, format: 'jpg', quality: 88, width: 800, widths: [800, 1600, 3000] });
const variants = [...jpg.srcSet.values].sort((a, b) => widthOf(a.descriptor ?? '') - widthOf(b.descriptor ?? ''));
const small = variants[0]?.url ?? jpg.src;
const large = variants[variants.length - 1]?.url ?? jpg.src;
const medium = variants.find((v) => widthOf(v.descriptor ?? '') === 1600)?.url ?? large;
const assetId = key.match(/^immich-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-/i)?.[1]?.toLowerCase() ?? null;
const statKey = ('portfolio/' + key.replace(/[^\w\-.]/g, '_')).slice(0, 150);
return {
img: file.default, key, alt, title: meta.title ?? null,
year, month, monthIndex, fullDate, model: exifData?.Model,
fStop, shutterSpeed, iso, focalLength,
dlS: small, dlM: medium, dlL: large, srcset: jpg.srcSet.attribute,
assetId, statKey,
};
}));
const uniqueYears = [...new Set(galleryItems.map((item: any) => item.year))].sort().reverse();
const allMonths = galleryItems.map((item: any) => ({ name: item.month, index: item.monthIndex }));
const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).values()]
.sort((a: any, b: any) => a.index - b.index)
.map((item: any) => item.name);
---
<MainLayout
title="Portfolio – fotografier av Linus Olofsson | Images by Olofsson"
description={`Portfolio med fotografier av Linus Olofsson: ${genreList()}. Filtrera på år och månad och öppna bilderna i större format.`}
path="/portfolio"
pageType="CollectionPage"
breadcrumb="Portfolio"
>
<div>
<WaveBackground opacity="opacity-55" />
</div>
<div class="container mx-auto px-4 py-12">
<div class="text-center mb-12">
<h1 class="text-4xl md:text-6xl font-bold mb-4">Portfolio</h1>
<p class="text-gray-300 max-w-2xl mx-auto">Ett urval av mina bilder i olika stilar – {genreList()}.
Vill du boka en fotografering? Läs om <a href="/tjanster" class="text-white underline underline-offset-4 hover:text-gray-300">tjänster och bokning</a>.</p>
</div>
<div class="flex flex-col items-center gap-6 mb-12">
<div class="flex flex-wrap justify-center gap-2">
<button type="button" class="filter-btn active" data-filter="all" data-type="year">Alla år</button>
{uniqueYears.map(year => (
<button type="button" class="filter-btn" data-filter={year} data-type="year">{year}</button>
))}
</div>
<div class="flex flex-wrap justify-center gap-2">
<button type="button" class="filter-btn active" data-filter="all" data-type="month">Alla månader</button>
{uniqueMonths.map(month => (
<button type="button" class="filter-btn capitalize" data-filter={month} data-type="month">{month}</button>
))}
</div>
</div>
<h2 class="sr-only">Bilder</h2>
<div class="columns-1 md:columns-2 lg:columns-3 gap-6 space-y-6" id="gallery-grid">
{galleryItems.map((item: any, index: number) => (
<a
href={item.dlL}
class="gallery-item block break-inside-avoid relative group overflow-hidden rounded-lg cursor-pointer bg-neutral-800 shadow-md"
data-year={item.year}
data-month={item.month}
data-src={item.dlL}
data-srcset={item.srcset}
data-dl-s={item.dlS}
data-dl-m={item.dlM}
data-dl-l={item.dlL}
data-key={item.key}
data-asset={item.assetId}
data-stat={item.statKey}
data-alt={item.alt}
data-title={item.title}
data-model={item.model}
data-iso={item.iso}
data-fstop={item.fStop}
data-shutter={item.shutterSpeed}
data-focal={item.focalLength}
data-date={item.fullDate}
>
<Image
src={item.img}
alt={item.alt}
width={800}
widths={[480, 800]}
sizes="(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw"
loading={index < 3 ? 'eager' : 'lazy'}
decoding="async"
class="w-full h-auto object-cover transform transition duration-700 group-hover:scale-105"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 transition duration-300 flex flex-col justify-end p-6 pointer-events-none">
{(item.title || item.fullDate) && <p class="text-white font-bold text-lg">{item.title ?? item.fullDate}</p>}
<p class="text-xs text-gray-300 uppercase tracking-widest mt-1">Visa bild</p>
</div>
</a>
))}
</div>
<div id="no-results" class="hidden text-center text-gray-400 mt-20">
<p class="text-xl">Inga bilder hittades för denna tidpunkt.</p>
<button type="button" id="reset-btn" class="mt-4 text-white underline hover:text-gray-300">Visa alla bilder</button>
</div>
</div>
<div id="lightbox" class="fixed inset-0 z-50 hidden bg-black/95 backdrop-blur-md flex items-center justify-center opacity-0 transition-opacity duration-300 p-2 md:p-8">
<div class="bg-neutral-900 w-full max-w-6xl h-[85vh] md:h-[90vh] flex flex-col rounded-xl overflow-hidden shadow-2xl relative border border-white/10">
<button type="button" id="lb-close" aria-label="Stäng" class="absolute top-4 right-4 z-50 bg-black/50 hover:bg-black/80 text-white p-2 rounded-full transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
<div class="flex-1 relative bg-black flex items-center justify-center overflow-hidden group">
<img id="lb-img" src="" alt="" sizes="100vw" class="max-h-full max-w-full w-full h-full object-contain p-2 md:p-4" />
<button type="button" id="lb-prev" aria-label="Föregående bild" class="absolute left-2 md:left-4 top-1/2 -translate-y-1/2 bg-black/40 hover:bg-black/70 text-white p-3 rounded-full transition-all">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>
</button>
<button type="button" id="lb-next" aria-label="Nästa bild" class="absolute right-2 md:right-4 top-1/2 -translate-y-1/2 bg-black/40 hover:bg-black/70 text-white p-3 rounded-full transition-all">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>
</button>
</div>
<div class="bg-neutral-800 p-4 md:p-6 shrink-0 border-t border-white/5 flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div class="text-left">
<p id="lb-date" class="text-white font-bold text-lg md:text-xl"></p>
<p id="lb-model" class="text-gray-400 text-sm font-medium tracking-wide mt-1"></p>
</div>
<div class="flex flex-wrap items-center gap-4 md:gap-6">
<div class="relative">
<button type="button" id="lb-download" aria-haspopup="true" aria-expanded="false" class="group inline-flex items-center gap-2 px-6 py-2.5 bg-neutral-700/80 hover:bg-neutral-600/80 text-gray-200 hover:text-white font-semibold text-xs uppercase tracking-widest rounded-full border border-black/60 shadow-lg shadow-black/30 ring-1 ring-white/5 transition-all duration-200 transform hover:scale-105 active:scale-95 active:duration-75">
<svg id="lb-dl-arrow" xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 transition-colors duration-200" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4M4 19h16"/></svg>
Ladda ner
</button>
<div id="lb-sizes" class="hidden absolute bottom-full mb-3 left-0 md:left-auto md:right-0 min-w-[260px] bg-neutral-900/95 backdrop-blur-md border border-white/10 rounded-xl shadow-2xl z-50 overflow-hidden origin-bottom opacity-0 translate-y-2 transition-all duration-200 ease-out">
<button type="button" data-size="s" class="dl-opt w-full text-left group flex items-center justify-between px-5 py-3.5 text-sm text-gray-200 hover:bg-black/40 hover:text-white cursor-pointer transition-colors border-b border-white/5"><span class="font-medium uppercase tracking-widest text-xs">Liten</span><span class="text-gray-400 text-xs font-mono group-hover:text-gray-300 transition-colors">800 px</span></button>
<button type="button" data-size="m" class="dl-opt w-full text-left group flex items-center justify-between px-5 py-3.5 text-sm text-gray-200 hover:bg-black/40 hover:text-white cursor-pointer transition-colors border-b border-white/5"><span class="font-medium uppercase tracking-widest text-xs">Mellan</span><span class="text-gray-400 text-xs font-mono group-hover:text-gray-300 transition-colors">1600 px</span></button>
<button type="button" data-size="l" class="dl-opt w-full text-left group flex items-center justify-between px-5 py-3.5 text-sm text-gray-200 hover:bg-black/40 hover:text-white cursor-pointer transition-colors border-b border-white/5"><span class="font-medium uppercase tracking-widest text-xs">Stor</span><span class="text-gray-400 text-xs font-mono group-hover:text-gray-300 transition-colors">3000 px</span></button>
<button type="button" data-size="orig" class="dl-opt w-full text-left group flex items-center justify-between px-5 py-3.5 text-sm text-gray-200 hover:bg-black/40 hover:text-white cursor-pointer transition-colors "><span class="font-medium uppercase tracking-widest text-xs">Original<span class="block text-[10px] text-gray-400 normal-case tracking-normal mt-0.5">Kräver lösenord</span></span><span class="text-gray-400 text-xs font-mono group-hover:text-gray-300 transition-colors">full storlek</span></button>
</div>
</div>
<div class="flex flex-wrap gap-3 md:gap-6 text-xs md:text-sm font-mono text-gray-300">
<div class="flex flex-col md:flex-row md:items-center gap-1"><span class="text-gray-400 uppercase text-[10px] font-bold">Brännvidd</span><span id="lb-focal">--</span></div>
<div class="flex flex-col md:flex-row md:items-center gap-1"><span class="text-gray-400 uppercase text-[10px] font-bold">Bländare</span><span id="lb-fstop">--</span></div>
<div class="flex flex-col md:flex-row md:items-center gap-1"><span class="text-gray-400 uppercase text-[10px] font-bold">Slutartid</span><span id="lb-shutter">--</span></div>
<div class="flex flex-col md:flex-row md:items-center gap-1"><span class="text-gray-400 uppercase text-[10px] font-bold">ISO</span><span id="lb-iso">--</span></div>
</div>
</div>
</div>
</div>
<!-- Lösenordsruta för ORIGINAL. Originalen ligger inte i repot (27–70 MB/st)
utan hämtas från Immich via /download/original, som kräver lösenord. -->
<div id="pw-modal" role="dialog" aria-modal="true" aria-labelledby="pw-title" class="hidden fixed inset-0 z-[60] bg-black/85 backdrop-blur-sm flex items-center justify-center p-4">
<div class="bg-neutral-900 border border-white/10 rounded-xl shadow-2xl p-7 w-full max-w-sm">
<p id="pw-title" class="text-white font-bold text-lg tracking-tight mb-1">Ladda ner original</p>
<p class="text-gray-400 text-sm mb-4">Originalfilen är i full upplösning och kräver lösenord.</p>
<input id="pw-input" type="password" aria-label="Lösenord" placeholder="Lösenord" autocomplete="current-password"
class="w-full bg-black/60 border border-white/20 rounded-lg text-white px-4 py-2.5 mb-2 focus:outline-none focus:border-white/60 focus:ring-1 focus:ring-white/20 transition" />
<p id="pw-error" class="hidden text-red-400 text-xs tracking-wide mb-2">Fel lösenord — försök igen.</p>
<div class="flex gap-2 justify-end mt-2">
<button type="button" id="pw-cancel" class="px-5 py-2.5 text-xs uppercase tracking-widest text-gray-400 hover:text-white rounded-full transition-colors">Avbryt</button>
<button type="button" id="pw-ok" class="px-5 py-2.5 text-xs uppercase tracking-widest bg-white text-black font-bold rounded-full hover:bg-gray-200 transition transform hover:scale-105">Ladda ner</button>
</div>
</div>
</div>
</div>
<script>
// --- ANONYM STATISTIK ---
// Räknare per dag och bild i kundportalen: ingen cookie, ingen IP, inget id.
const STATS_URL = 'https://customer.imagesbyolofsson.se/api/stats/hit';
// Bildens statistiknyckel räknas ut vid bygget (data-stat) och är densamma som
// tidigare ("portfolio/<filnamn utan ändelse>"), så historiken fortsätter.
function statsItem(item?: HTMLElement): string {
return item?.dataset.stat || 'portfolio/okand';
}
function stat(kind: 'view' | 'open' | 'download', item: string) {
try {
const body = new Blob([JSON.stringify({ kind, item })], { type: 'text/plain' });
navigator.sendBeacon?.(STATS_URL, body);
} catch { /* statistik får aldrig störa sidan */ }
}
stat('view', 'page/portfolio');
// --- FILTER LOGIK ---
// MULTI-select: flera år + månader samtidigt. 'all' = samtligt.
let selectedYears = new Set(['all']);
let selectedMonths = new Set(['all']);
const items = Array.from(document.querySelectorAll('.gallery-item')) as HTMLElement[];
const buttons = document.querySelectorAll('.filter-btn');
const noResults = document.getElementById('no-results');
const resetBtn = document.getElementById('reset-btn');
function filterImages() {
let visibleCount = 0;
items.forEach((el) => {
const itemYear = el.dataset.year;
const itemMonth = el.dataset.month;
const matchYear = selectedYears.has('all') || selectedYears.has(itemYear);
const matchMonth = selectedMonths.has('all') || selectedMonths.has(itemMonth);
if (matchYear && matchMonth) {
el.style.display = 'block';
el.classList.add('visible-item');
if(el.style.opacity === '0') {
el.animate([{ opacity: 0, transform: 'scale(0.95)' }, { opacity: 1, transform: 'scale(1)' }], { duration: 300, easing: 'ease-out' });
}
visibleCount++;
} else {
el.style.display = 'none';
el.classList.remove('visible-item');
}
});
if (noResults) noResults.style.display = visibleCount === 0 ? 'block' : 'none';
}
// --- LIGHTBOX LOGIK ---
const lightbox = document.getElementById('lightbox');
const lbImg = document.getElementById('lb-img') as HTMLImageElement;
const lbDate = document.getElementById('lb-date');
const lbModel = document.getElementById('lb-model');
const lbFocal = document.getElementById('lb-focal');
const lbFstop = document.getElementById('lb-fstop');
const lbShutter = document.getElementById('lb-shutter');
const lbIso = document.getElementById('lb-iso');
const closeBtn = document.getElementById('lb-close');
const nextBtn = document.getElementById('lb-next');
const prevBtn = document.getElementById('lb-prev');
let currentImageIndex = 0;
let visibleItems: HTMLElement[] = [];
function updateVisibleItems() {
visibleItems = items.filter(item => item.style.display !== 'none');
}
function openLightbox(index: number) {
updateVisibleItems();
if (visibleItems.length === 0) return;
currentImageIndex = index;
updateLightboxContent();
lightbox?.classList.remove('hidden');
setTimeout(() => { lightbox?.classList.remove('opacity-0'); }, 10);
document.body.style.overflow = 'hidden';
}
// --- NEDLADDNING (Small/Medium/Large ur repot, Original via proxy) ---
const dlBtn = document.getElementById('lb-download');
const dlMenu = document.getElementById('lb-sizes');
const pwModal = document.getElementById('pw-modal');
const pwInput = document.getElementById('pw-input') as HTMLInputElement;
const pwError = document.getElementById('pw-error');
// Small/Medium/Large är EXIF-fria JPG:er som Astro tar fram vid bygget
// (data-dl-s/m/l). Original hämtas från Immich via proxyn och kräver lösenord.
function urlForSize(size: string): string | null {
const item = visibleItems[currentImageIndex];
if (!item) return null;
if (size === 's') return item.dataset.dlS || null;
if (size === 'm') return item.dataset.dlM || null;
if (size === 'l') return item.dataset.dlL || null;
if (size === 'orig') return item.dataset.asset ? `/download/original?asset=${item.dataset.asset}` : null;
return null;
}
function fileNameFor(size: string): string {
const item = visibleItems[currentImageIndex];
const key = (item?.dataset.key || 'bild').replace(/[^\w\-]/g, '_');
return `imagesbyolofsson-${key}-${size}.jpg`;
}
function startDownload(url: string, filnamn?: string) {
const a = document.createElement('a');
a.href = url;
a.download = filnamn || '';
document.body.appendChild(a);
a.click();
a.remove();
}
const dlArrow = document.getElementById('lb-dl-arrow');
function visaMeny(visa: boolean) {
if (!dlMenu) return;
// Pilen går grå medan menyn är öppen och tillbaka till vanligt när den stängs
// (både vid val av storlek och när man klickar bort menyn).
dlArrow?.classList.toggle('text-gray-500', visa);
dlBtn?.setAttribute('aria-expanded', visa ? 'true' : 'false');
if (visa) {
dlMenu.classList.remove('hidden');
requestAnimationFrame(() => dlMenu.classList.remove('opacity-0', 'translate-y-2'));
} else {
dlMenu.classList.add('opacity-0', 'translate-y-2');
setTimeout(() => dlMenu.classList.add('hidden'), 180);
}
}
dlBtn?.addEventListener('click', (e) => {
e.stopPropagation();
visaMeny(dlMenu?.classList.contains('hidden') ?? true);
});
document.addEventListener('click', (e) => {
if (!dlMenu || dlMenu.classList.contains('hidden')) return;
if (!dlMenu.contains(e.target as Node) && !dlBtn?.contains(e.target as Node)) visaMeny(false);
});
document.querySelectorAll('.dl-opt').forEach((opt) => {
opt.addEventListener('click', () => {
const size = (opt as HTMLElement).dataset.size || 'l';
visaMeny(false);
const url = urlForSize(size);
if (!url) { alert('Den storleken saknas för den här bilden.'); return; }
stat('download', statsItem(visibleItems[currentImageIndex]));
if (size === 'orig') {
// Lösenordet sparas bara för fliken, aldrig i localStorage.
const sparat = sessionStorage.getItem('dlpw');
if (sparat) { startDownload(`${url}&pw=${encodeURIComponent(sparat)}`); return; }
pwError?.classList.add('hidden');
if (pwInput) pwInput.value = '';
pwModal?.classList.remove('hidden');
setTimeout(() => pwInput?.focus(), 50);
} else {
startDownload(url, fileNameFor(size));
}
});
});
async function forsokOriginal() {
const pw = pwInput?.value || '';
if (!pw) return;
const url = urlForSize('orig');
if (!url) return;
// HEAD först: då får vi 401 utan att dra igång en 70 MB-nedladdning.
try {
const r = await fetch(`${url}&pw=${encodeURIComponent(pw)}`, { method: 'HEAD' });
if (r.status === 401) { pwError?.classList.remove('hidden'); return; }
if (!r.ok) { alert('Kunde inte hämta originalet just nu.'); return; }
} catch { /* nätverksfel -> försök ändå nedan */ }
sessionStorage.setItem('dlpw', pw);
pwModal?.classList.add('hidden');
startDownload(`${url}&pw=${encodeURIComponent(pw)}`);
}
document.getElementById('pw-ok')?.addEventListener('click', forsokOriginal);
pwInput?.addEventListener('keydown', (e) => { if ((e as KeyboardEvent).key === 'Enter') forsokOriginal(); });
document.getElementById('pw-cancel')?.addEventListener('click', () => pwModal?.classList.add('hidden'));
document.addEventListener('keydown', (e) => {
if ((e as KeyboardEvent).key === 'Escape' && !dlMenu?.classList.contains('hidden')) {
e.stopImmediatePropagation();
visaMeny(false);
}
}, true);
function closeLightbox() {
lightbox?.classList.add('opacity-0');
setTimeout(() => {
lightbox?.classList.add('hidden');
document.body.style.overflow = '';
}, 300);
}
function updateLightboxContent() {
const item = visibleItems[currentImageIndex];
if (!item) return;
visaMeny(false);
// srcset först så att webbläsaren väljer 800/1600/3000 px efter skärmen.
lbImg.srcset = item.dataset.srcset || '';
lbImg.src = item.dataset.src || '';
lbImg.alt = item.dataset.alt || '';
stat('open', statsItem(item));
if(lbDate) lbDate.innerText = [item.dataset.title, item.dataset.date].filter(Boolean).join(' – ');
if(lbModel) lbModel.innerText = item.dataset.model || '';
if(lbFocal) lbFocal.innerText = item.dataset.focal || '--';
if(lbFstop) lbFstop.innerText = item.dataset.fstop || '--';
if(lbShutter) lbShutter.innerText = item.dataset.shutter ? item.dataset.shutter + 's' : '--';
if(lbIso) lbIso.innerText = item.dataset.iso || '--';
}
function showNext() {
currentImageIndex = (currentImageIndex + 1) % visibleItems.length;
updateLightboxContent();
}
function showPrev() {
currentImageIndex = (currentImageIndex - 1 + visibleItems.length) % visibleItems.length;
updateLightboxContent();
}
// Event Listeners
items.forEach((item) => {
item.classList.add('visible-item');
item.addEventListener('click', (e) => {
// Länken pekar på den stora bilden (crawlbar/öppningsbar i ny flik),
// men ett vanligt klick öppnar lightboxen.
const me = e as MouseEvent;
if (me.metaKey || me.ctrlKey || me.shiftKey || me.button === 1) return;
e.preventDefault();
updateVisibleItems();
const index = visibleItems.indexOf(item);
if (index !== -1) openLightbox(index);
});
});
closeBtn?.addEventListener('click', closeLightbox);
nextBtn?.addEventListener('click', (e) => { e.stopPropagation(); showNext(); });
prevBtn?.addEventListener('click', (e) => { e.stopPropagation(); showPrev(); });
lightbox?.addEventListener('click', (e) => {
if (e.target === lightbox) closeLightbox();
});
document.addEventListener('keydown', (e) => {
if (lightbox?.classList.contains('hidden')) return;
if (e.key === 'Escape') closeLightbox();
if (e.key === 'ArrowRight') showNext();
if (e.key === 'ArrowLeft') showPrev();
});
// Filter Listeners
buttons.forEach(btn => {
btn.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const type = target.dataset.type;
const value = target.dataset.filter;
if (!type || !value) return;
const sel = type === 'year' ? selectedYears : selectedMonths;
if (value === 'all') { sel.clear(); sel.add('all'); }
else {
sel.delete('all');
sel.has(value) ? sel.delete(value) : sel.add(value);
if (sel.size === 0) sel.add('all');
}
document.querySelectorAll(`.filter-btn[data-type="${type}"]`).forEach(b => {
b.classList.toggle('active', b.dataset.filter === 'all' ? sel.has('all') : sel.has(b.dataset.filter));
});
filterImages();
});
});
resetBtn?.addEventListener('click', () => {
selectedYears = new Set(['all']);
selectedMonths = new Set(['all']);
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
document.querySelector('.filter-btn[data-filter="all"][data-type="year"]')?.classList.add('active');
document.querySelector('.filter-btn[data-filter="all"][data-type="month"]')?.classList.add('active');
filterImages();
});
</script>
</MainLayout>
<style>
@reference "../styles/global.css";
.filter-btn {
@apply px-4 py-1.5 rounded-full border border-white/10 bg-neutral-800/50 text-sm text-gray-400 transition hover:bg-white hover:text-black hover:border-white;
}
.filter-btn.active {
@apply bg-white text-black border-white font-medium shadow-[0_0_15px_rgba(255,255,255,0.3)];
}
</style>