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:
co-authored by
Claude Opus 5.5
parent
4f1b468f75
commit
1e921dfe37
+422
-359
@@ -1,31 +1,32 @@
|
||||
---
|
||||
// src/pages/portfolio.astro
|
||||
import MainLayout from '../layouts/MainLayout.astro';
|
||||
import { Image } from 'astro:assets';
|
||||
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 ---
|
||||
// SERVER-SIDE LOGIC (byggtid)
|
||||
|
||||
// 1. Hämta alla bilder
|
||||
const allImageFiles = import.meta.glob('../assets/images/portfolio/*.{jpeg,jpg,png,webp,JPG,JPEG,PNG}', { eager: true });
|
||||
// -s (800px) och -m (1600px) ar NEDLADDNINGSVARIANTER, inte egna galleribilder.
|
||||
// Utan filtret visas varje bild tre ganger.
|
||||
const imageFiles = Object.fromEntries(
|
||||
Object.entries(allImageFiles).filter(([pathKey]) => !/-[sm]\.(jpe?g|png|webp)$/i.test(pathKey))
|
||||
// 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 }
|
||||
);
|
||||
|
||||
// Karta assetId -> { s, m } med Astros hashade URL:er, sa klienten kan lanka
|
||||
// till ratt nedladdningsvariant. Large ar galleribilden sjalv, original gar via proxy.
|
||||
const downloadVariants: Record<string, Record<string, string>> = {};
|
||||
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;
|
||||
}
|
||||
// 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;
|
||||
@@ -34,40 +35,78 @@ function formatShutterSpeed(exposureTime: number) {
|
||||
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 = null;
|
||||
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'
|
||||
]);
|
||||
dateTaken = exifData?.DateTimeOriginal || exifData?.CreateDate;
|
||||
if (!dateTaken) dateTaken = new Date();
|
||||
const exifDate = exifData?.DateTimeOriginal || exifData?.CreateDate;
|
||||
if (exifDate) dateTaken = new Date(exifDate);
|
||||
} catch (e) {
|
||||
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 month = dateObj.toLocaleString('sv-SE', { month: 'long' }).toLowerCase();
|
||||
const monthIndex = dateObj.getMonth();
|
||||
|
||||
const fullDate = dateObj.toLocaleDateString('sv-SE', {
|
||||
day: 'numeric', month: 'long', year: 'numeric'
|
||||
});
|
||||
|
||||
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 {
|
||||
...file, year, month, monthIndex, fullDate, model: exifData?.Model,
|
||||
fStop, shutterSpeed, iso, focalLength
|
||||
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,
|
||||
};
|
||||
}));
|
||||
|
||||
@@ -78,7 +117,13 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
|
||||
.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>
|
||||
<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">
|
||||
<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 class="flex flex-col items-center gap-6 mb-12">
|
||||
<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 => (
|
||||
<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 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 => (
|
||||
<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>
|
||||
|
||||
<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) => (
|
||||
<div
|
||||
class="gallery-item break-inside-avoid relative group overflow-hidden rounded-lg cursor-pointer bg-neutral-800 shadow-md"
|
||||
{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.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-iso={item.iso}
|
||||
data-fstop={item.fStop}
|
||||
@@ -121,24 +178,28 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
|
||||
data-focal={item.focalLength}
|
||||
data-date={item.fullDate}
|
||||
>
|
||||
<Image
|
||||
src={item.default}
|
||||
alt={`Foto taget med ${item.model}`}
|
||||
width={800}
|
||||
<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 transition duration-300 flex flex-col justify-end p-6 pointer-events-none">
|
||||
<p class="text-white font-bold text-lg">{item.fullDate}</p>
|
||||
<p class="text-xs text-gray-400 uppercase tracking-widest mt-1">Visa bild</p>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</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>
|
||||
<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>
|
||||
@@ -146,67 +207,354 @@ 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 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>
|
||||
</button>
|
||||
|
||||
<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" />
|
||||
<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">
|
||||
<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 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>
|
||||
</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">
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-4 md:gap-6">
|
||||
<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>
|
||||
Download
|
||||
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">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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="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-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-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-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-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">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>
|
||||
|
||||
<script type="application/json" id="dl-variants" set:html={JSON.stringify(downloadVariants)}></script>
|
||||
|
||||
<!-- Losenordsruta for ORIGINAL. Originalen ligger inte i repot (27-70 MB/st)
|
||||
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">
|
||||
<!-- 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">
|
||||
<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>
|
||||
<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" />
|
||||
<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 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-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>
|
||||
@@ -220,288 +568,3 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
|
||||
@apply bg-white text-black border-white font-medium shadow-[0_0_15px_rgba(255,255,255,0.3)];
|
||||
}
|
||||
</style>
|
||||
|
||||
<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';
|
||||
function statsItem(src: string): string {
|
||||
const file = (src.split('/').pop() || '').replace(/\.[A-Za-z0-9_-]{6,12}\.(jpe?g|webp|avif|png)$/i, '');
|
||||
return ('portfolio/' + file.replace(/[^\w\-.]/g, '_')).slice(0, 150);
|
||||
}
|
||||
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 ar + manader 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');
|
||||
|
||||
// Astro hashar filnamnen, men assetId:t finns kvar i dem:
|
||||
// /_astro/immich-<assetId>-<datum>-<namn>.<hash>.jpg
|
||||
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 {
|
||||
const item = visibleItems[currentImageIndex];
|
||||
if (!item) return null;
|
||||
const large = item.dataset.src || '';
|
||||
if (size === 'l') return large;
|
||||
const id = assetIdFor(large);
|
||||
if (!id) return null;
|
||||
if (size === 'orig') return `/download/original?asset=${id}`;
|
||||
return VARIANTS[id]?.[size] || null;
|
||||
}
|
||||
|
||||
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 gar gra medan menyn ar oppen och tillbaka till vanligt nar den stangs
|
||||
// (bade vid val av storlek och nar man klickar bort menyn).
|
||||
dlArrow?.classList.toggle('text-gray-500', visa);
|
||||
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 for den har bilden.'); return; }
|
||||
stat('download', statsItem(visibleItems[currentImageIndex]?.dataset.src || ''));
|
||||
if (size === 'orig') {
|
||||
// Losenordet sparas bara for 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function forsokOriginal() {
|
||||
const pw = pwInput?.value || '';
|
||||
if (!pw) return;
|
||||
const url = urlForSize('orig');
|
||||
if (!url) return;
|
||||
// HEAD forst: da far vi 401 utan att dra igang 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 hamta originalet just nu.'); return; }
|
||||
} catch { /* natverksfel -> forsok anda 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);
|
||||
|
||||
lbImg.src = item.dataset.src || '';
|
||||
stat('open', statsItem(item.dataset.src || ''));
|
||||
|
||||
if(lbDate) lbDate.innerText = item.dataset.date || '';
|
||||
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', () => {
|
||||
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>
|
||||
Reference in New Issue
Block a user