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
This commit is contained in:
Linus Olofsson
2026-10-27 19:13:24 +01:00
co-authored by Claude Opus 5.5
parent 4f1b468f75
commit 1e921dfe37
3 changed files with 452 additions and 369 deletions
+3 -3
View File
@@ -8,15 +8,15 @@ A modern, responsive photography portfolio website built with Astro, Tailwind CS
<table> <table>
<tr> <tr>
<td align="center"> <td align="center">
<img src="https://raw.githubusercontent.com/Images-by-Olofsson/homepage/main/public/3d-scroll/img1.jpg" width="250" alt="Portfolio Grid"> <img src="https://raw.githubusercontent.com/Images-by-Olofsson/homepage/main/src/assets/images/3d-scroll/img1.jpg" width="250" alt="Portfolio Grid">
<br><em> More </em> <br><em> More </em>
</td> </td>
<td align="center"> <td align="center">
<img src="https://raw.githubusercontent.com/Images-by-Olofsson/homepage/main/public/3d-scroll/img2.jpg" width="250" alt="3D Scroll Effect"> <img src="https://raw.githubusercontent.com/Images-by-Olofsson/homepage/main/src/assets/images/3d-scroll/img2.jpg" width="250" alt="3D Scroll Effect">
<br><em> At </em> <br><em> At </em>
</td> </td>
<td align="center"> <td align="center">
<img src="https://raw.githubusercontent.com/Images-by-Olofsson/homepage/main/public/3d-scroll/img3.jpg" width="250" alt="Hero Section"> <img src="https://raw.githubusercontent.com/Images-by-Olofsson/homepage/main/src/assets/images/3d-scroll/img3.jpg" width="250" alt="Hero Section">
<br><em>Imagesbyolofsson.se</em> <br><em>Imagesbyolofsson.se</em>
</td> </td>
</tr> </tr>
+26 -6
View File
@@ -1,13 +1,22 @@
--- ---
// src/components/ThreeDScroll.astro // src/components/ThreeDScroll.astro
// Bilderna ligger i src/assets så att Astro skalar om dem (AVIF/WebP/JPG, srcset)
// och rensar bort EXIF (GPS, serienummer m.m.). Originalen publiceras inte.
import { Picture } from 'astro:assets';
import img1 from '../assets/images/3d-scroll/img1.jpg';
import img2 from '../assets/images/3d-scroll/img2.jpg';
import img3 from '../assets/images/3d-scroll/img3.jpg';
import img4 from '../assets/images/3d-scroll/img4.jpg';
import img5 from '../assets/images/3d-scroll/img5.jpg';
const images = [ const images = [
{ src: "/3d-scroll/img1.jpg", alt: "Architecture Dark" }, { src: img1, alt: 'Närbild av en gammal läderinbunden bok med slitna pappersark i varmt motljus' },
{ src: "/3d-scroll/img2.jpg", alt: "Shadows Light" }, { src: img2, alt: 'Veteranbil med tända strålkastare på en stadsgata i kvällsljus' },
{ src: "/3d-scroll/img3.jpg", alt: "Portrait High End" }, { src: img3, alt: 'Överblommade tistlar bland höga grässtrån i dämpade bruna toner' },
{ src: "/3d-scroll/img4.jpg", alt: "Minimalist Landscape" }, { src: img4, alt: 'Järnvägsspår som slingrar sig mellan träd i motljus, i sepiaton' },
{ src: "/3d-scroll/img5.jpg", alt: "Foggy Forest" }, { src: img5, alt: 'Svartvit bild av en man som bär ett litet barn bland såpbubblor i motljus' },
]; ];
const last = images.length - 1;
--- ---
<div id="scroll-container" class="relative h-[400vh] w-full"> <div id="scroll-container" class="relative h-[400vh] w-full">
@@ -20,9 +29,20 @@ const images = [
class="gallery-item absolute transform-style-3d will-change-transform flex items-center justify-center" class="gallery-item absolute transform-style-3d will-change-transform flex items-center justify-center"
data-index={index} data-index={index}
> >
<img {/* Första bilden: eager + hög prioritet. Den sista ligger överst innan man
scrollat (alla bilder står på samma plats tills scriptet flyttar dem),
så den laddas också direkt. Övriga laddas lat. */}
<Picture
src={img.src} src={img.src}
alt={img.alt} alt={img.alt}
widths={[640, 1024, 1600, 2048]}
sizes="80vw"
formats={['avif', 'webp']}
fallbackFormat="jpg"
quality={78}
loading={index === 0 || index === last ? 'eager' : 'lazy'}
fetchpriority={index === 0 ? 'high' : 'auto'}
decoding="async"
class="floating-animation max-w-[80vw] max-h-[70vh] w-auto h-auto object-contain rounded-lg shadow-[0_20px_60px_-15px_rgba(0,0,0,0.8)] border border-white/5 grayscale hover:grayscale-0 transition-all duration-700 ease-out" class="floating-animation max-w-[80vw] max-h-[70vh] w-auto h-auto object-contain rounded-lg shadow-[0_20px_60px_-15px_rgba(0,0,0,0.8)] border border-white/5 grayscale hover:grayscale-0 transition-all duration-700 ease-out"
/> />
</div> </div>
+182 -119
View File
@@ -1,31 +1,32 @@
--- ---
// src/pages/portfolio.astro // src/pages/portfolio.astro
import MainLayout from '../layouts/MainLayout.astro'; import MainLayout from '../layouts/MainLayout.astro';
import { Image } from 'astro:assets'; import { Image, getImage } from 'astro:assets';
import exifr from 'exifr'; import exifr from 'exifr';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import WaveBackground from '../components/WaveBackground.astro'; import WaveBackground from '../components/WaveBackground.astro';
import { business, genreList } from '../data/business';
import { portfolioMeta } from '../data/portfolio-meta';
// --- SERVER-SIDE LOGIC --- // SERVER-SIDE LOGIC (byggtid)
// 1. Hämta alla bilder // 1. Hämta alla galleribilder.
const allImageFiles = import.meta.glob('../assets/images/portfolio/*.{jpeg,jpg,png,webp,JPG,JPEG,PNG}', { eager: true }); // -s (800px) och -m (1600px) i mappen är gamla nedladdningsvarianter med EXIF kvar.
// -s (800px) och -m (1600px) ar NEDLADDNINGSVARIANTER, inte egna galleribilder. // De importeras INTE längre (då hamnar de inte i dist/); nedladdningsstorlekarna
// Utan filtret visas varje bild tre ganger. // tas i stället fram av Astros bildtjänst nedan, vilket också rensar bort EXIF
const imageFiles = Object.fromEntries( // (GPS, kamerans serienummer, Artist m.m.).
Object.entries(allImageFiles).filter(([pathKey]) => !/-[sm]\.(jpe?g|png|webp)$/i.test(pathKey)) 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 }
); );
// Karta assetId -> { s, m } med Astros hashade URL:er, sa klienten kan lanka // OBS: läs aldrig .src/.width/.height direkt på de importerade bilderna här.
// till ratt nedladdningsvariant. Large ar galleribilden sjalv, original gar via proxy. // Då räknar Astro originalet som "använt" och kopierar originalfilen (med EXIF)
const downloadVariants: Record<string, Record<string, string>> = {}; // till dist/. Gå alltid via <Image>/getImage.
for (const [pathKey, file] of Object.entries(allImageFiles) as [string, any][]) {
const m = pathKey.match(/immich-([0-9a-f-]{36})-.*-([sm])\.(?:jpe?g|png|webp)$/i);
if (!m) continue;
const id = m[1].toLowerCase();
(downloadVariants[id] ||= {})[m[2].toLowerCase()] = file.default.src;
}
function formatShutterSpeed(exposureTime: number) { function formatShutterSpeed(exposureTime: number) {
if (!exposureTime) return null; if (!exposureTime) return null;
@@ -34,40 +35,78 @@ function formatShutterSpeed(exposureTime: number) {
return `1/${fraction}`; 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]) => { const galleryItems = await Promise.all(Object.entries(imageFiles).map(async ([relativePath, file]: [string, any]) => {
let exifData: any = {}; let exifData: any = {};
let dateTaken = null; let dateTaken: Date | null = null;
const absolutePath = path.resolve('./src/pages', relativePath); 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 { try {
const fileBuffer = fs.readFileSync(absolutePath); const fileBuffer = fs.readFileSync(absolutePath);
exifData = await exifr.parse(fileBuffer, [ exifData = await exifr.parse(fileBuffer, [
'ISO', 'Model', 'FNumber', 'ExposureTime', 'FocalLength', 'DateTimeOriginal', 'CreateDate' 'ISO', 'Model', 'FNumber', 'ExposureTime', 'FocalLength', 'DateTimeOriginal', 'CreateDate'
]); ]);
dateTaken = exifData?.DateTimeOriginal || exifData?.CreateDate; const exifDate = exifData?.DateTimeOriginal || exifData?.CreateDate;
if (!dateTaken) dateTaken = new Date(); if (exifDate) dateTaken = new Date(exifDate);
} catch (e) { } catch (e) {
console.error(`Kunde inte läsa EXIF för ${relativePath}`); console.error(`Kunde inte läsa EXIF för ${relativePath}`);
dateTaken = new Date();
} }
if (!dateTaken || isNaN(dateTaken.getTime())) dateTaken = dateFromFilename(fileName);
const hasDate = !!dateTaken;
const dateObj = dateTaken ?? new Date();
const dateObj = new Date(dateTaken);
const year = dateObj.getFullYear().toString(); const year = dateObj.getFullYear().toString();
const month = dateObj.toLocaleString('sv-SE', { month: 'long' }).toLowerCase(); const month = dateObj.toLocaleString('sv-SE', { month: 'long' }).toLowerCase();
const monthIndex = dateObj.getMonth(); const monthIndex = dateObj.getMonth();
const fullDate = dateObj.toLocaleDateString('sv-SE', { const fullDate = hasDate
day: 'numeric', month: 'long', year: 'numeric' ? dateObj.toLocaleDateString('sv-SE', { day: 'numeric', month: 'long', year: 'numeric' })
}); : '';
const shutterSpeed = formatShutterSpeed(exifData?.ExposureTime); const shutterSpeed = formatShutterSpeed(exifData?.ExposureTime);
const focalLength = exifData?.FocalLength ? `${Math.round(exifData.FocalLength)}mm` : null; const focalLength = exifData?.FocalLength ? `${Math.round(exifData.FocalLength)}mm` : null;
const fStop = exifData?.FNumber ? `f/${exifData.FNumber}` : null; const fStop = exifData?.FNumber ? `f/${exifData.FNumber}` : null;
const iso = exifData?.ISO ? `ISO ${exifData.ISO}` : 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 { return {
...file, year, month, monthIndex, fullDate, model: exifData?.Model, img: file.default, key, alt, title: meta.title ?? null,
fStop, shutterSpeed, iso, focalLength year, month, monthIndex, fullDate, model: exifData?.Model,
fStop, shutterSpeed, iso, focalLength,
dlS: small, dlM: medium, dlL: large, srcset: jpg.srcSet.attribute,
assetId, statKey,
}; };
})); }));
@@ -78,7 +117,13 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
.map((item: any) => item.name); .map((item: any) => item.name);
--- ---
<MainLayout title="Portfolio | Images by Olofsson"> <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> <div>
<WaveBackground opacity="opacity-55" /> <WaveBackground opacity="opacity-55" />
@@ -88,32 +133,44 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
<div class="text-center mb-12"> <div class="text-center mb-12">
<h1 class="text-4xl md:text-6xl font-bold mb-4">Portfolio</h1> <h1 class="text-4xl md:text-6xl font-bold mb-4">Portfolio</h1>
<p class="text-gray-400">A curated selection of my work across different styles.</p> <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>
<div class="flex flex-col items-center gap-6 mb-12"> <div class="flex flex-col items-center gap-6 mb-12">
<div class="flex flex-wrap justify-center gap-2"> <div class="flex flex-wrap justify-center gap-2">
<button class="filter-btn active" data-filter="all" data-type="year">Alla år</button> <button type="button" class="filter-btn active" data-filter="all" data-type="year">Alla år</button>
{uniqueYears.map(year => ( {uniqueYears.map(year => (
<button class="filter-btn" data-filter={year} data-type="year">{year}</button> <button type="button" class="filter-btn" data-filter={year} data-type="year">{year}</button>
))} ))}
</div> </div>
<div class="flex flex-wrap justify-center gap-2"> <div class="flex flex-wrap justify-center gap-2">
<button class="filter-btn active" data-filter="all" data-type="month">Alla månader</button> <button type="button" class="filter-btn active" data-filter="all" data-type="month">Alla månader</button>
{uniqueMonths.map(month => ( {uniqueMonths.map(month => (
<button class="filter-btn capitalize" data-filter={month} data-type="month">{month}</button> <button type="button" class="filter-btn capitalize" data-filter={month} data-type="month">{month}</button>
))} ))}
</div> </div>
</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"> <div class="columns-1 md:columns-2 lg:columns-3 gap-6 space-y-6" id="gallery-grid">
{galleryItems.map((item: any) => ( {galleryItems.map((item: any, index: number) => (
<div <a
class="gallery-item break-inside-avoid relative group overflow-hidden rounded-lg cursor-pointer bg-neutral-800 shadow-md" 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-year={item.year}
data-month={item.month} data-month={item.month}
data-src={item.default.src} 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-model={item.model}
data-iso={item.iso} data-iso={item.iso}
data-fstop={item.fStop} data-fstop={item.fStop}
@@ -122,23 +179,27 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
data-date={item.fullDate} data-date={item.fullDate}
> >
<Image <Image
src={item.default} src={item.img}
alt={`Foto taget med ${item.model}`} alt={item.alt}
width={800} 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" 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 transition duration-300 flex flex-col justify-end p-6 pointer-events-none"> <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">
<p class="text-white font-bold text-lg">{item.fullDate}</p> {(item.title || item.fullDate) && <p class="text-white font-bold text-lg">{item.title ?? item.fullDate}</p>}
<p class="text-xs text-gray-400 uppercase tracking-widest mt-1">Visa bild</p> <p class="text-xs text-gray-300 uppercase tracking-widest mt-1">Visa bild</p>
</div>
</div> </div>
</a>
))} ))}
</div> </div>
<div id="no-results" class="hidden text-center text-gray-500 mt-20"> <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> <p class="text-xl">Inga bilder hittades för denna tidpunkt.</p>
<button id="reset-btn" class="mt-4 text-white underline hover:text-gray-300">Visa alla bilder</button> <button type="button" id="reset-btn" class="mt-4 text-white underline hover:text-gray-300">Visa alla bilder</button>
</div> </div>
</div> </div>
@@ -146,88 +207,74 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
<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 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"> <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 id="lb-close" class="absolute top-4 right-4 z-50 bg-black/50 hover:bg-black/80 text-white p-2 rounded-full transition-colors"> <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> <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> </button>
<div class="flex-1 relative bg-black flex items-center justify-center overflow-hidden group"> <div class="flex-1 relative bg-black flex items-center justify-center overflow-hidden group">
<img id="lb-img" src="" alt="Full size" class="max-h-full max-w-full w-full h-full object-contain p-2 md:p-4" /> <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 id="lb-prev" 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"> <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> <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>
<button id="lb-next" 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"> <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> <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> </button>
</div> </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="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"> <div class="text-left">
<h3 id="lb-date" class="text-white font-bold text-lg md:text-xl capitalize"></h3> <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> <p id="lb-model" class="text-gray-400 text-sm font-medium tracking-wide mt-1"></p>
</div> </div>
<div class="flex flex-wrap items-center gap-4 md:gap-6"> <div class="flex flex-wrap items-center gap-4 md:gap-6">
<div class="relative"> <div class="relative">
<button id="lb-download" 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"> <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> <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>
Download Ladda ner
</button> </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"> <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">
<a data-size="s" class="dl-opt 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">Small</span><span class="text-gray-500 text-xs font-mono group-hover:text-gray-300 transition-colors">800 px</span></a> <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>
<a data-size="m" class="dl-opt 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">Medium</span><span class="text-gray-500 text-xs font-mono group-hover:text-gray-300 transition-colors">1600 px</span></a> <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>
<a data-size="l" class="dl-opt 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">Large</span><span class="text-gray-500 text-xs font-mono group-hover:text-gray-300 transition-colors">3000 px</span></a> <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>
<a data-size="orig" class="dl-opt 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-500 normal-case tracking-normal mt-0.5">Kräver lösenord</span></span><span class="text-gray-500 text-xs font-mono group-hover:text-gray-300 transition-colors">full storlek</span></a> <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> </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-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-500 uppercase text-[10px] font-bold">Focal</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">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-500 uppercase text-[10px] font-bold">Aperture</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">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-500 uppercase text-[10px] font-bold">Shutter</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">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-500 uppercase text-[10px] font-bold">ISO</span><span id="lb-iso">--</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>
</div> </div>
</div> </div>
<script type="application/json" id="dl-variants" set:html={JSON.stringify(downloadVariants)}></script> <!-- 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. -->
<!-- Losenordsruta for ORIGINAL. Originalen ligger inte i repot (27-70 MB/st) <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">
utan hamtas fran Immich via /download/original, som kraver losenord. -->
<div id="pw-modal" 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"> <div class="bg-neutral-900 border border-white/10 rounded-xl shadow-2xl p-7 w-full max-w-sm">
<h4 class="text-white font-bold text-lg tracking-tight mb-1">Ladda ner original</h4> <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> <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" placeholder="Lösenord" autocomplete="current-password" <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" /> 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> <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"> <div class="flex gap-2 justify-end mt-2">
<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-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 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> <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>
</div> </div>
</div> </div>
</MainLayout>
<style> <script>
@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>
<script>
// --- ANONYM STATISTIK --- // --- ANONYM STATISTIK ---
// Räknare per dag och bild i kundportalen: ingen cookie, ingen IP, inget id. // Räknare per dag och bild i kundportalen: ingen cookie, ingen IP, inget id.
const STATS_URL = 'https://customer.imagesbyolofsson.se/api/stats/hit'; const STATS_URL = 'https://customer.imagesbyolofsson.se/api/stats/hit';
function statsItem(src: string): string { // Bildens statistiknyckel räknas ut vid bygget (data-stat) och är densamma som
const file = (src.split('/').pop() || '').replace(/\.[A-Za-z0-9_-]{6,12}\.(jpe?g|webp|avif|png)$/i, ''); // tidigare ("portfolio/<filnamn utan ändelse>"), så historiken fortsätter.
return ('portfolio/' + file.replace(/[^\w\-.]/g, '_')).slice(0, 150); function statsItem(item?: HTMLElement): string {
return item?.dataset.stat || 'portfolio/okand';
} }
function stat(kind: 'view' | 'open' | 'download', item: string) { function stat(kind: 'view' | 'open' | 'download', item: string) {
try { try {
@@ -238,7 +285,7 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
stat('view', 'page/portfolio'); stat('view', 'page/portfolio');
// --- FILTER LOGIK --- // --- FILTER LOGIK ---
// MULTI-select: flera ar + manader samtidigt. 'all' = samtligt. // MULTI-select: flera år + månader samtidigt. 'all' = samtligt.
let selectedYears = new Set(['all']); let selectedYears = new Set(['all']);
let selectedMonths = new Set(['all']); let selectedMonths = new Set(['all']);
@@ -308,28 +355,22 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
const pwInput = document.getElementById('pw-input') as HTMLInputElement; const pwInput = document.getElementById('pw-input') as HTMLInputElement;
const pwError = document.getElementById('pw-error'); const pwError = document.getElementById('pw-error');
// Astro hashar filnamnen, men assetId:t finns kvar i dem: // Small/Medium/Large är EXIF-fria JPG:er som Astro tar fram vid bygget
// /_astro/immich-<assetId>-<datum>-<namn>.<hash>.jpg // (data-dl-s/m/l). Original hämtas från Immich via proxyn och kräver lösenord.
function assetIdFor(src: string): string | null {
const m = src.match(/immich-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-/i);
return m ? m[1].toLowerCase() : null;
}
// Large ar filen galleriet redan visar. -s/-m ligger bredvid i samma katalog,
// men med egen Astro-hash - darfor laser vi dem ur en genererad karta.
const VARIANTS: Record<string, Record<string, string>> = JSON.parse(
document.getElementById('dl-variants')?.textContent || '{}'
);
function urlForSize(size: string): string | null { function urlForSize(size: string): string | null {
const item = visibleItems[currentImageIndex]; const item = visibleItems[currentImageIndex];
if (!item) return null; if (!item) return null;
const large = item.dataset.src || ''; if (size === 's') return item.dataset.dlS || null;
if (size === 'l') return large; if (size === 'm') return item.dataset.dlM || null;
const id = assetIdFor(large); if (size === 'l') return item.dataset.dlL || null;
if (!id) return null; if (size === 'orig') return item.dataset.asset ? `/download/original?asset=${item.dataset.asset}` : null;
if (size === 'orig') return `/download/original?asset=${id}`; return null;
return VARIANTS[id]?.[size] || 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) { function startDownload(url: string, filnamn?: string) {
@@ -345,9 +386,10 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
function visaMeny(visa: boolean) { function visaMeny(visa: boolean) {
if (!dlMenu) return; if (!dlMenu) return;
// Pilen gar gra medan menyn ar oppen och tillbaka till vanligt nar den stangs // Pilen går grå medan menyn är öppen och tillbaka till vanligt när den stängs
// (bade vid val av storlek och nar man klickar bort menyn). // (både vid val av storlek och när man klickar bort menyn).
dlArrow?.classList.toggle('text-gray-500', visa); dlArrow?.classList.toggle('text-gray-500', visa);
dlBtn?.setAttribute('aria-expanded', visa ? 'true' : 'false');
if (visa) { if (visa) {
dlMenu.classList.remove('hidden'); dlMenu.classList.remove('hidden');
requestAnimationFrame(() => dlMenu.classList.remove('opacity-0', 'translate-y-2')); requestAnimationFrame(() => dlMenu.classList.remove('opacity-0', 'translate-y-2'));
@@ -372,10 +414,10 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
const size = (opt as HTMLElement).dataset.size || 'l'; const size = (opt as HTMLElement).dataset.size || 'l';
visaMeny(false); visaMeny(false);
const url = urlForSize(size); const url = urlForSize(size);
if (!url) { alert('Den storleken saknas for den har bilden.'); return; } if (!url) { alert('Den storleken saknas för den här bilden.'); return; }
stat('download', statsItem(visibleItems[currentImageIndex]?.dataset.src || '')); stat('download', statsItem(visibleItems[currentImageIndex]));
if (size === 'orig') { if (size === 'orig') {
// Losenordet sparas bara for fliken, aldrig i localStorage. // Lösenordet sparas bara för fliken, aldrig i localStorage.
const sparat = sessionStorage.getItem('dlpw'); const sparat = sessionStorage.getItem('dlpw');
if (sparat) { startDownload(`${url}&pw=${encodeURIComponent(sparat)}`); return; } if (sparat) { startDownload(`${url}&pw=${encodeURIComponent(sparat)}`); return; }
pwError?.classList.add('hidden'); pwError?.classList.add('hidden');
@@ -383,7 +425,7 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
pwModal?.classList.remove('hidden'); pwModal?.classList.remove('hidden');
setTimeout(() => pwInput?.focus(), 50); setTimeout(() => pwInput?.focus(), 50);
} else { } else {
startDownload(url); startDownload(url, fileNameFor(size));
} }
}); });
}); });
@@ -393,12 +435,12 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
if (!pw) return; if (!pw) return;
const url = urlForSize('orig'); const url = urlForSize('orig');
if (!url) return; if (!url) return;
// HEAD forst: da far vi 401 utan att dra igang en 70 MB-nedladdning. // HEAD först: då får vi 401 utan att dra igång en 70 MB-nedladdning.
try { try {
const r = await fetch(`${url}&pw=${encodeURIComponent(pw)}`, { method: 'HEAD' }); const r = await fetch(`${url}&pw=${encodeURIComponent(pw)}`, { method: 'HEAD' });
if (r.status === 401) { pwError?.classList.remove('hidden'); return; } if (r.status === 401) { pwError?.classList.remove('hidden'); return; }
if (!r.ok) { alert('Kunde inte hamta originalet just nu.'); return; } if (!r.ok) { alert('Kunde inte hämta originalet just nu.'); return; }
} catch { /* natverksfel -> forsok anda nedan */ } } catch { /* nätverksfel -> försök ändå nedan */ }
sessionStorage.setItem('dlpw', pw); sessionStorage.setItem('dlpw', pw);
pwModal?.classList.add('hidden'); pwModal?.classList.add('hidden');
startDownload(`${url}&pw=${encodeURIComponent(pw)}`); startDownload(`${url}&pw=${encodeURIComponent(pw)}`);
@@ -428,10 +470,13 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
if (!item) return; if (!item) return;
visaMeny(false); 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.src = item.dataset.src || '';
stat('open', statsItem(item.dataset.src || '')); lbImg.alt = item.dataset.alt || '';
stat('open', statsItem(item));
if(lbDate) lbDate.innerText = item.dataset.date || ''; if(lbDate) lbDate.innerText = [item.dataset.title, item.dataset.date].filter(Boolean).join(' – ');
if(lbModel) lbModel.innerText = item.dataset.model || ''; if(lbModel) lbModel.innerText = item.dataset.model || '';
if(lbFocal) lbFocal.innerText = item.dataset.focal || '--'; if(lbFocal) lbFocal.innerText = item.dataset.focal || '--';
if(lbFstop) lbFstop.innerText = item.dataset.fstop || '--'; if(lbFstop) lbFstop.innerText = item.dataset.fstop || '--';
@@ -452,7 +497,12 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
// Event Listeners // Event Listeners
items.forEach((item) => { items.forEach((item) => {
item.classList.add('visible-item'); item.classList.add('visible-item');
item.addEventListener('click', () => { 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(); updateVisibleItems();
const index = visibleItems.indexOf(item); const index = visibleItems.indexOf(item);
if (index !== -1) openLightbox(index); if (index !== -1) openLightbox(index);
@@ -504,4 +554,17 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
document.querySelector('.filter-btn[data-filter="all"][data-type="month"]')?.classList.add('active'); document.querySelector('.filter-btn[data-filter="all"][data-type="month"]')?.classList.add('active');
filterImages(); filterImages();
}); });
</script> </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>