feat(portfolio): nedladdningsmeny med Small/Medium/Large/Original

DOWNLOAD-knapp i lightboxen oppnar en storleksmeny. S/M/L pekar pa filer i
repot; Original gar till /download/original och oppnar en losenordsruta.
Losenordet sparas i sessionStorage (bara fliken), och HEAD-anrop anvands sa
fel losenord ger 401 utan att dra igang en 70 MB-nedladdning.

🔴 Globen filtrerar nu bort -s/-m ur galleriet - utan det visades varje bild
tre ganger. Variantkartan (assetId -> hashade URL:er) genereras i frontmattern.
This commit is contained in:
portfolio-sync
2026-09-19 16:11:13 +00:00
parent 0d3da3d102
commit 59383f703f
+134 -1
View File
@@ -10,7 +10,22 @@ import WaveBackground from '../components/WaveBackground.astro';
// --- SERVER-SIDE LOGIC --- // --- SERVER-SIDE LOGIC ---
// 1. Hämta alla bilder // 1. Hämta alla bilder
const imageFiles = import.meta.glob('../assets/images/portfolio/*.{jpeg,jpg,png,webp,JPG,JPEG,PNG}', { eager: true }); 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))
);
// 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;
}
function formatShutterSpeed(exposureTime: number) { function formatShutterSpeed(exposureTime: number) {
if (!exposureTime) return null; if (!exposureTime) return null;
@@ -150,6 +165,15 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
<h3 id="lb-date" class="text-white font-bold text-lg md:text-xl capitalize"></h3> <h3 id="lb-date" class="text-white font-bold text-lg md:text-xl capitalize"></h3>
<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="relative">
<button id="lb-download" class="bg-white text-black font-bold tracking-wide px-6 py-3 hover:bg-gray-200 transition-colors">DOWNLOAD</button>
<div id="lb-sizes" class="hidden absolute bottom-full mb-2 left-0 min-w-[220px] bg-neutral-900 border border-white/10 shadow-xl z-50">
<a data-size="s" class="dl-opt block px-4 py-3 text-sm text-gray-200 hover:bg-white/10 cursor-pointer border-b border-white/5">Small <span class="text-gray-500 float-right">800 px</span></a>
<a data-size="m" class="dl-opt block px-4 py-3 text-sm text-gray-200 hover:bg-white/10 cursor-pointer border-b border-white/5">Medium <span class="text-gray-500 float-right">1600 px</span></a>
<a data-size="l" class="dl-opt block px-4 py-3 text-sm text-gray-200 hover:bg-white/10 cursor-pointer border-b border-white/5">Large <span class="text-gray-500 float-right">3000 px</span></a>
<a data-size="orig" class="dl-opt block px-4 py-3 text-sm text-white hover:bg-white/10 cursor-pointer">Original <span class="text-gray-500 float-right">full storlek</span></a>
</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-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">Aperture</span><span id="lb-fstop">--</span></div>
@@ -158,6 +182,24 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
</div> </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/80 flex items-center justify-center p-4">
<div class="bg-neutral-900 border border-white/10 p-6 w-full max-w-sm">
<h4 class="text-white font-bold text-lg mb-1">Ladda ner original</h4>
<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"
class="w-full bg-black border border-white/20 text-white px-3 py-2 mb-2 focus:outline-none focus:border-white/60" />
<p id="pw-error" class="hidden text-red-400 text-sm 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-4 py-2 text-sm text-gray-400 hover:text-white">Avbryt</button>
<button id="pw-ok" class="px-4 py-2 text-sm bg-white text-black font-bold hover:bg-gray-200">Ladda ner</button>
</div>
</div>
</div>
</div> </div>
</MainLayout> </MainLayout>
@@ -239,6 +281,96 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
document.body.style.overflow = 'hidden'; 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();
}
dlBtn?.addEventListener('click', (e) => {
e.stopPropagation();
dlMenu?.classList.toggle('hidden');
});
document.addEventListener('click', (e) => {
if (!dlMenu || dlMenu.classList.contains('hidden')) return;
if (!dlMenu.contains(e.target as Node) && e.target !== dlBtn) dlMenu.classList.add('hidden');
});
document.querySelectorAll('.dl-opt').forEach((opt) => {
opt.addEventListener('click', () => {
const size = (opt as HTMLElement).dataset.size || 'l';
dlMenu?.classList.add('hidden');
const url = urlForSize(size);
if (!url) { alert('Den storleken saknas for den har bilden.'); return; }
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'));
function closeLightbox() { function closeLightbox() {
lightbox?.classList.add('opacity-0'); lightbox?.classList.add('opacity-0');
setTimeout(() => { setTimeout(() => {
@@ -250,6 +382,7 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value
function updateLightboxContent() { function updateLightboxContent() {
const item = visibleItems[currentImageIndex]; const item = visibleItems[currentImageIndex];
if (!item) return; if (!item) return;
dlMenu?.classList.add('hidden');
lbImg.src = item.dataset.src || ''; lbImg.src = item.dataset.src || '';