- 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
571 lines
29 KiB
Plaintext
571 lines
29 KiB
Plaintext
---
|
||
// 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>
|