Files
imagesbyolofsson-se/src/pages/portfolio.astro
T
Linus OlofssonandClaude Opus 5.5 1b24607e02 Portfolio without downloads; Om mig, Kontakt and Posters come from the portal on the same domain
Linus 2026-11-06: everything public lives on imagesbyolofsson.se (Cleura), the customer
subdomain is only for ordering and the logged-in portal; photos are sold as posters
instead of being downloadable.

- Portfolio: download menu (S/M/L), the original password dialog and /download/original
  are gone; the lightbox has "Köp som poster" -> /posters instead.
- about.astro and contact.astro removed: Caddy on Cleura sends /about, /contact and
  /posters to the portal, so the About text and collaborations are edited once in admin.
  They stay in sitemap-0.xml (PORTAL_PAGES); the sitemap index lists /posters/sitemap.xml.
- Menu and 404 page link Posters; privacy, robots.txt and llms.txt no longer mention
  password downloads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 04:08:04 +01:00

429 lines
20 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);
// Lightbox: EXIF-fria JPG:er i 800/1600/3000 px (Astro hoppar över bredder större än originalet).
// Ingen nedladdning längre (Linus 2026-11-06): bilderna säljs som posters i stället.
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 large = variants[variants.length - 1]?.url ?? jpg.src;
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,
large, srcset: jpg.srcSet.attribute,
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.large}
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.large}
data-srcset={item.srcset}
data-key={item.key}
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">
<a href="/posters" class="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">
Köp som poster
</a>
<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>
</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', 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';
}
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;
// 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>