Initial commit: Modern photography portfolio with Astro

- Built with Astro 5.16.7 and Tailwind CSS 4.1.18
- Features advanced animations (waves, 3D scroll, smoke effects)
- Fully responsive design with mobile-first approach
- SEO optimized with semantic HTML
- Interactive elements and smooth transitions
- Professional portfolio showcase
This commit is contained in:
Linus Olofsson
2026-01-28 00:05:23 +01:00
commit b0b633d32b
84 changed files with 1656 additions and 0 deletions
+322
View File
@@ -0,0 +1,322 @@
---
// src/pages/portfolio.astro
import MainLayout from '../layouts/MainLayout.astro';
import { Image } from 'astro:assets';
import exifr from 'exifr';
import fs from 'node:fs';
import path from 'node:path';
import WaveBackground from '../components/WaveBackground.astro';
// --- SERVER-SIDE LOGIC ---
// 1. Hämta alla bilder
const imageFiles = import.meta.glob('../assets/images/portfolio/*.{jpeg,jpg,png,webp,JPG,JPEG,PNG}', { eager: true });
function formatShutterSpeed(exposureTime: number) {
if (!exposureTime) return null;
if (exposureTime >= 1) return exposureTime.toString();
const fraction = Math.round(1 / exposureTime);
return `1/${fraction}`;
}
const galleryItems = await Promise.all(Object.entries(imageFiles).map(async ([relativePath, file]: [string, any]) => {
let exifData: any = {};
let dateTaken = null;
const absolutePath = path.resolve('./src/pages', relativePath);
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();
} catch (e) {
console.error(`Kunde inte läsa EXIF för ${relativePath}`);
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 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;
return {
...file, year, month, monthIndex, fullDate, model: exifData?.Model,
fStop, shutterSpeed, iso, focalLength
};
}));
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 | Images by Olofsson">
<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-400">A curated selection of my work across different styles.</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>
{uniqueYears.map(year => (
<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>
{uniqueMonths.map(month => (
<button class="filter-btn capitalize" data-filter={month} data-type="month">{month}</button>
))}
</div>
</div>
<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"
data-year={item.year}
data-month={item.month}
data-src={item.default.src}
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.default}
alt={`Foto taget med ${item.model}`}
width={800}
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>
</div>
))}
</div>
<div id="no-results" class="hidden text-center text-gray-500 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>
</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 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">
<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">
<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">
<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-model" class="text-gray-400 text-sm font-medium tracking-wide mt-1"></p>
</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>
</div>
</div>
</div>
</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>
<script>
// --- FILTER LOGIK ---
let currentYear = 'all';
let currentMonth = '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 = currentYear === 'all' || itemYear === currentYear;
const matchMonth = currentMonth === 'all' || itemMonth === currentMonth;
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;
lbImg.src = 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;
document.querySelectorAll(`.filter-btn[data-type="${type}"]`).forEach(b => b.classList.remove('active'));
target.classList.add('active');
if (type === 'year') currentYear = value;
if (type === 'month') currentMonth = value;
filterImages();
});
});
resetBtn?.addEventListener('click', () => {
currentYear = 'all';
currentMonth = '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>