--- // 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 /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--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); ---

Portfolio

Ett urval av mina bilder i olika stilar – {genreList()}. Vill du boka en fotografering? Läs om tjänster och bokning.

{uniqueYears.map(year => ( ))}
{uniqueMonths.map(month => ( ))}

Bilder