Add photos page
just to see how we like it
This commit is contained in:
parent
691d0cf464
commit
77a0e7cdd5
8 changed files with 1197 additions and 6 deletions
5
src/assets/icons/photos.svg
Normal file
5
src/assets/icons/photos.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="2" y="4" width="16" height="12" rx="2" stroke="currentColor" stroke-width="1.5" fill="none"/>
|
||||||
|
<circle cx="6.5" cy="8.5" r="1.5" fill="currentColor"/>
|
||||||
|
<path d="m12 12 2-2 4 4v2a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-2l4-4 2 2" stroke="currentColor" stroke-width="1.5" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 390 B |
88
src/lib/components/PhotoGrid.svelte
Normal file
88
src/lib/components/PhotoGrid.svelte
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import PhotoItem from '$components/PhotoItem.svelte'
|
||||||
|
import PhotoLightbox from '$components/PhotoLightbox.svelte'
|
||||||
|
import type { PhotoItem as PhotoItemType, Photo } from '$lib/types/photos'
|
||||||
|
|
||||||
|
const { photoItems }: { photoItems: PhotoItemType[] } = $props()
|
||||||
|
|
||||||
|
let lightboxPhoto: Photo | null = $state(null)
|
||||||
|
let lightboxAlbumPhotos: Photo[] = $state([])
|
||||||
|
let lightboxIndex = $state(0)
|
||||||
|
|
||||||
|
function openLightbox(photo: Photo, albumPhotos?: Photo[]) {
|
||||||
|
if (albumPhotos && albumPhotos.length > 0) {
|
||||||
|
// For albums, start with the first photo, not the cover photo
|
||||||
|
lightboxAlbumPhotos = albumPhotos
|
||||||
|
lightboxIndex = 0
|
||||||
|
lightboxPhoto = albumPhotos[0]
|
||||||
|
} else {
|
||||||
|
// For individual photos
|
||||||
|
lightboxPhoto = photo
|
||||||
|
lightboxAlbumPhotos = []
|
||||||
|
lightboxIndex = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLightbox() {
|
||||||
|
lightboxPhoto = null
|
||||||
|
lightboxAlbumPhotos = []
|
||||||
|
lightboxIndex = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateLightbox(direction: 'prev' | 'next') {
|
||||||
|
if (lightboxAlbumPhotos.length === 0) return
|
||||||
|
|
||||||
|
if (direction === 'prev') {
|
||||||
|
lightboxIndex = lightboxIndex > 0 ? lightboxIndex - 1 : lightboxAlbumPhotos.length - 1
|
||||||
|
} else {
|
||||||
|
lightboxIndex = lightboxIndex < lightboxAlbumPhotos.length - 1 ? lightboxIndex + 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
lightboxPhoto = lightboxAlbumPhotos[lightboxIndex]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="photo-grid-container">
|
||||||
|
<div class="photo-grid">
|
||||||
|
{#each photoItems as item}
|
||||||
|
<PhotoItem {item} onPhotoClick={openLightbox} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if lightboxPhoto}
|
||||||
|
<PhotoLightbox
|
||||||
|
photo={lightboxPhoto}
|
||||||
|
albumPhotos={lightboxAlbumPhotos}
|
||||||
|
currentIndex={lightboxIndex}
|
||||||
|
onClose={closeLightbox}
|
||||||
|
onNavigate={navigateLightbox}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.photo-grid-container {
|
||||||
|
width: 100%;
|
||||||
|
padding: $unit-6x $unit-2x;
|
||||||
|
|
||||||
|
@include breakpoint('phone') {
|
||||||
|
padding: $unit-3x $unit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-grid {
|
||||||
|
columns: 3;
|
||||||
|
column-gap: $unit-2x;
|
||||||
|
max-width: 700px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
@include breakpoint('tablet') {
|
||||||
|
columns: 2;
|
||||||
|
column-gap: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include breakpoint('phone') {
|
||||||
|
columns: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
255
src/lib/components/PhotoItem.svelte
Normal file
255
src/lib/components/PhotoItem.svelte
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PhotoItem, Photo, PhotoAlbum } from '$lib/types/photos'
|
||||||
|
import { isAlbum } from '$lib/types/photos'
|
||||||
|
|
||||||
|
const {
|
||||||
|
item,
|
||||||
|
onPhotoClick
|
||||||
|
}: {
|
||||||
|
item: PhotoItem
|
||||||
|
onPhotoClick: (photo: Photo, albumPhotos?: Photo[]) => void
|
||||||
|
} = $props()
|
||||||
|
|
||||||
|
let imageLoaded = $state(false)
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
if (isAlbum(item)) {
|
||||||
|
// For albums, open the cover photo with album navigation
|
||||||
|
onPhotoClick(item.coverPhoto, item.photos)
|
||||||
|
} else {
|
||||||
|
// For individual photos, open just that photo
|
||||||
|
onPhotoClick(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImageLoad() {
|
||||||
|
imageLoaded = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const photo = $derived(isAlbum(item) ? item.coverPhoto : item)
|
||||||
|
const isAlbumItem = $derived(isAlbum(item))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="photo-item" class:is-album={isAlbumItem}>
|
||||||
|
<button class="photo-button" onclick={handleClick} type="button">
|
||||||
|
{#if isAlbumItem}
|
||||||
|
<!-- Stack effect for albums -->
|
||||||
|
<div class="album-stack">
|
||||||
|
<div class="stack-photo stack-back"></div>
|
||||||
|
<div class="stack-photo stack-middle"></div>
|
||||||
|
<div class="stack-photo stack-front">
|
||||||
|
<img
|
||||||
|
src={photo.src}
|
||||||
|
alt={photo.alt}
|
||||||
|
loading="lazy"
|
||||||
|
draggable="false"
|
||||||
|
onload={handleImageLoad}
|
||||||
|
class:loaded={imageLoaded}
|
||||||
|
/>
|
||||||
|
{#if !imageLoaded}
|
||||||
|
<div class="image-placeholder"></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="album-overlay">
|
||||||
|
<div class="album-info">
|
||||||
|
<span class="album-title">{item.title}</span>
|
||||||
|
<span class="album-count">{item.photos.length} photos</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Single photo -->
|
||||||
|
<div class="single-photo">
|
||||||
|
<img
|
||||||
|
src={photo.src}
|
||||||
|
alt={photo.alt}
|
||||||
|
loading="lazy"
|
||||||
|
draggable="false"
|
||||||
|
onload={handleImageLoad}
|
||||||
|
class:loaded={imageLoaded}
|
||||||
|
/>
|
||||||
|
{#if !imageLoaded}
|
||||||
|
<div class="image-placeholder"></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.photo-item {
|
||||||
|
break-inside: avoid;
|
||||||
|
margin-bottom: $unit-2x;
|
||||||
|
|
||||||
|
@include breakpoint('tablet') {
|
||||||
|
margin-bottom: $unit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-button {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: $corner-radius;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.single-photo {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
border-radius: $corner-radius;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.4s ease;
|
||||||
|
|
||||||
|
&.loaded {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-stack {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-photo {
|
||||||
|
border-radius: $corner-radius;
|
||||||
|
|
||||||
|
&.stack-back {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
left: 6px;
|
||||||
|
right: -6px;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 1;
|
||||||
|
transform: rotate(2deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.stack-middle {
|
||||||
|
position: absolute;
|
||||||
|
top: -3px;
|
||||||
|
left: 3px;
|
||||||
|
right: -3px;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
z-index: 2;
|
||||||
|
transform: rotate(-1deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.stack-front {
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
border-radius: $corner-radius;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.4s ease;
|
||||||
|
|
||||||
|
&.loaded {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-overlay {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
|
||||||
|
color: white;
|
||||||
|
padding: $unit-2x;
|
||||||
|
z-index: 4;
|
||||||
|
border-radius: 0 0 $corner-radius $corner-radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-half;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-album {
|
||||||
|
.photo-button:hover {
|
||||||
|
.stack-back {
|
||||||
|
transform: rotate(3deg) translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-middle {
|
||||||
|
transform: rotate(-1.5deg) translateY(-0.5px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-placeholder {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(135deg, #f0f0f0 0%, #e0e0e0 50%, #f0f0f0 100%);
|
||||||
|
background-size: 200% 200%;
|
||||||
|
animation: shimmer 1.5s ease-in-out infinite;
|
||||||
|
border-radius: $corner-radius;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 50%;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23999' stroke-width='1.5'%3E%3Crect x='3' y='3' width='18' height='18' rx='2' ry='2'/%3E%3Ccircle cx='8.5' cy='8.5' r='1.5'/%3E%3Cpolyline points='21,15 16,10 5,21'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: 24px 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
364
src/lib/components/PhotoLightbox.svelte
Normal file
364
src/lib/components/PhotoLightbox.svelte
Normal file
|
|
@ -0,0 +1,364 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Photo } from '$lib/types/photos'
|
||||||
|
|
||||||
|
const {
|
||||||
|
photo,
|
||||||
|
albumPhotos = [],
|
||||||
|
currentIndex = 0,
|
||||||
|
onClose,
|
||||||
|
onNavigate
|
||||||
|
}: {
|
||||||
|
photo: Photo
|
||||||
|
albumPhotos?: Photo[]
|
||||||
|
currentIndex?: number
|
||||||
|
onClose: () => void
|
||||||
|
onNavigate: (direction: 'prev' | 'next') => void
|
||||||
|
} = $props()
|
||||||
|
|
||||||
|
let imageLoaded = $state(false)
|
||||||
|
let currentPhotoId = $state(photo.id)
|
||||||
|
|
||||||
|
const hasNavigation = $derived(albumPhotos.length > 1)
|
||||||
|
const hasExifData = $derived(photo.exif && Object.keys(photo.exif).length > 0)
|
||||||
|
|
||||||
|
// Reset loading state when photo changes
|
||||||
|
$effect(() => {
|
||||||
|
if (photo.id !== currentPhotoId) {
|
||||||
|
imageLoaded = false
|
||||||
|
currentPhotoId = photo.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleImageLoad() {
|
||||||
|
imageLoaded = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
switch (event.key) {
|
||||||
|
case 'Escape':
|
||||||
|
onClose()
|
||||||
|
break
|
||||||
|
case 'ArrowLeft':
|
||||||
|
if (hasNavigation) onNavigate('prev')
|
||||||
|
break
|
||||||
|
case 'ArrowRight':
|
||||||
|
if (hasNavigation) onNavigate('next')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackdropClick(event: MouseEvent) {
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatExifValue(key: string, value: string): string {
|
||||||
|
switch (key) {
|
||||||
|
case 'dateTaken':
|
||||||
|
return new Date(value).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExifLabel(key: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
camera: 'Camera',
|
||||||
|
lens: 'Lens',
|
||||||
|
focalLength: 'Focal Length',
|
||||||
|
aperture: 'Aperture',
|
||||||
|
shutterSpeed: 'Shutter Speed',
|
||||||
|
iso: 'ISO',
|
||||||
|
dateTaken: 'Date Taken',
|
||||||
|
location: 'Location'
|
||||||
|
}
|
||||||
|
return labels[key] || key
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
<div class="lightbox-backdrop" onclick={handleBackdropClick}>
|
||||||
|
<div class="lightbox-container">
|
||||||
|
<!-- Close button -->
|
||||||
|
<button class="close-button" onclick={onClose} type="button">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M6 6l12 12M18 6l-12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Navigation buttons -->
|
||||||
|
{#if hasNavigation}
|
||||||
|
<button class="nav-button nav-prev" onclick={() => onNavigate('prev')} type="button">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="nav-button nav-next" onclick={() => onNavigate('next')} type="button">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Photo -->
|
||||||
|
<div class="photo-container">
|
||||||
|
<img
|
||||||
|
src={photo.src}
|
||||||
|
alt={photo.alt}
|
||||||
|
onload={handleImageLoad}
|
||||||
|
class:loaded={imageLoaded}
|
||||||
|
/>
|
||||||
|
{#if !imageLoaded}
|
||||||
|
<div class="loading-indicator">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Photo info -->
|
||||||
|
<div class="photo-info" class:loaded={imageLoaded}>
|
||||||
|
{#if photo.caption}
|
||||||
|
<h3 class="photo-caption">{photo.caption}</h3>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if hasExifData}
|
||||||
|
<div class="exif-data">
|
||||||
|
<h4>Camera Settings</h4>
|
||||||
|
<dl class="exif-list">
|
||||||
|
{#each Object.entries(photo.exif) as [key, value]}
|
||||||
|
<div class="exif-item">
|
||||||
|
<dt>{getExifLabel(key)}</dt>
|
||||||
|
<dd>{formatExifValue(key, value)}</dd>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if hasNavigation}
|
||||||
|
<div class="navigation-info">
|
||||||
|
{currentIndex + 1} of {albumPhotos.length}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.lightbox-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.95);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: $unit-2x;
|
||||||
|
|
||||||
|
@include breakpoint('phone') {
|
||||||
|
padding: $unit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-container {
|
||||||
|
position: relative;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
gap: $unit-3x;
|
||||||
|
|
||||||
|
@include breakpoint('phone') {
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 95vw;
|
||||||
|
max-height: 95vh;
|
||||||
|
gap: $unit-2x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button {
|
||||||
|
position: absolute;
|
||||||
|
top: -$unit-6x;
|
||||||
|
right: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: $unit;
|
||||||
|
border-radius: $corner-radius;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@include breakpoint('phone') {
|
||||||
|
top: -$unit-4x;
|
||||||
|
right: -$unit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: $unit-2x;
|
||||||
|
border-radius: $corner-radius;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.nav-prev {
|
||||||
|
left: -$unit-6x;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.nav-next {
|
||||||
|
right: -$unit-6x;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include breakpoint('phone') {
|
||||||
|
&.nav-prev {
|
||||||
|
left: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.nav-next {
|
||||||
|
right: $unit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-container {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 70vw;
|
||||||
|
max-height: 80vh;
|
||||||
|
height: auto;
|
||||||
|
border-radius: $corner-radius;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
|
||||||
|
&.loaded {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include breakpoint('phone') {
|
||||||
|
max-width: 95vw;
|
||||||
|
max-height: 60vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-indicator {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-info {
|
||||||
|
width: 300px;
|
||||||
|
color: white;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: $unit-2x;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: $corner-radius;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease 0.1s; // Slight delay to sync with image
|
||||||
|
|
||||||
|
&.loaded {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include breakpoint('phone') {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 30vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-caption {
|
||||||
|
margin: 0 0 $unit-3x 0;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exif-data {
|
||||||
|
margin-bottom: $unit-3x;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0 0 $unit-2x 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.exif-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exif-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
|
||||||
|
dt {
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-right: $unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
text-align: right;
|
||||||
|
font-family: 'Monaco', 'Menlo', monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.navigation-info {
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import WorkIcon from '$icons/work.svg'
|
import WorkIcon from '$icons/work.svg'
|
||||||
import LabsIcon from '$icons/labs.svg'
|
import LabsIcon from '$icons/labs.svg'
|
||||||
import UniverseIcon from '$icons/universe.svg'
|
import UniverseIcon from '$icons/universe.svg'
|
||||||
|
import PhotosIcon from '$icons/photos.svg'
|
||||||
import { page } from '$app/stores'
|
import { page } from '$app/stores'
|
||||||
|
|
||||||
const currentPath = $derived($page.url.pathname)
|
const currentPath = $derived($page.url.pathname)
|
||||||
|
|
@ -10,11 +11,12 @@
|
||||||
icon: typeof WorkIcon
|
icon: typeof WorkIcon
|
||||||
text: string
|
text: string
|
||||||
href: string
|
href: string
|
||||||
variant: 'work' | 'universe' | 'labs'
|
variant: 'work' | 'universe' | 'labs' | 'photos'
|
||||||
}
|
}
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ icon: WorkIcon, text: 'Work', href: '/', variant: 'work' },
|
{ icon: WorkIcon, text: 'Work', href: '/', variant: 'work' },
|
||||||
|
{ icon: PhotosIcon, text: 'Photos', href: '/photos', variant: 'photos' },
|
||||||
{ icon: LabsIcon, text: 'Labs', href: '#', variant: 'labs' },
|
{ icon: LabsIcon, text: 'Labs', href: '#', variant: 'labs' },
|
||||||
{ icon: UniverseIcon, text: 'Universe', href: '/universe', variant: 'universe' }
|
{ icon: UniverseIcon, text: 'Universe', href: '/universe', variant: 'universe' }
|
||||||
]
|
]
|
||||||
|
|
@ -25,7 +27,8 @@
|
||||||
// Calculate active index based on current path
|
// Calculate active index based on current path
|
||||||
const activeIndex = $derived(
|
const activeIndex = $derived(
|
||||||
currentPath === '/' ? 0 :
|
currentPath === '/' ? 0 :
|
||||||
currentPath.startsWith('/universe') ? 2 :
|
currentPath.startsWith('/photos') ? 1 :
|
||||||
|
currentPath.startsWith('/universe') ? 3 :
|
||||||
-1
|
-1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -64,6 +67,7 @@
|
||||||
function getBgColor(variant: string): string {
|
function getBgColor(variant: string): string {
|
||||||
switch (variant) {
|
switch (variant) {
|
||||||
case 'work': return '#ffcdc5' // $work-bg
|
case 'work': return '#ffcdc5' // $work-bg
|
||||||
|
case 'photos': return '#e8c5ff' // $photos-bg (purple)
|
||||||
case 'universe': return '#ffebc5' // $universe-bg
|
case 'universe': return '#ffebc5' // $universe-bg
|
||||||
case 'labs': return '#c5eaff' // $labs-bg
|
case 'labs': return '#c5eaff' // $labs-bg
|
||||||
default: return '#c5eaff'
|
default: return '#c5eaff'
|
||||||
|
|
@ -74,6 +78,7 @@
|
||||||
function getTextColor(variant: string): string {
|
function getTextColor(variant: string): string {
|
||||||
switch (variant) {
|
switch (variant) {
|
||||||
case 'work': return '#d0290d' // $work-color
|
case 'work': return '#d0290d' // $work-color
|
||||||
|
case 'photos': return '#7c3aed' // $photos-color (purple)
|
||||||
case 'universe': return '#b97d14' // $universe-color
|
case 'universe': return '#b97d14' // $universe-color
|
||||||
case 'labs': return '#1482c1' // $labs-color
|
case 'labs': return '#1482c1' // $labs-color
|
||||||
default: return '#1482c1'
|
default: return '#1482c1'
|
||||||
|
|
@ -157,18 +162,23 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Different animations for each nav item
|
// Different animations for each nav item
|
||||||
// First item after the sliding pill is Work (index 1)
|
// First item is Work (index 1)
|
||||||
.nav-item:nth-of-type(1) :global(svg.animate) {
|
.nav-item:nth-of-type(1) :global(svg.animate) {
|
||||||
animation: cursorWiggle 0.6s ease;
|
animation: cursorWiggle 0.6s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second item is Labs (index 2)
|
// Second item is Photos (index 2)
|
||||||
.nav-item:nth-of-type(2) :global(svg.animate) {
|
.nav-item:nth-of-type(2) :global(svg.animate) {
|
||||||
|
animation: photoFlash 0.6s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Third item is Labs (index 3)
|
||||||
|
.nav-item:nth-of-type(3) :global(svg.animate) {
|
||||||
animation: tubeBubble 0.6s ease;
|
animation: tubeBubble 0.6s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Third item is Universe (index 3)
|
// Fourth item is Universe (index 4)
|
||||||
.nav-item:nth-of-type(3) :global(svg.animate) {
|
.nav-item:nth-of-type(4) :global(svg.animate) {
|
||||||
animation: starSpin 0.6s ease;
|
animation: starSpin 0.6s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -178,6 +188,11 @@
|
||||||
75% { transform: rotate(8deg) scale(1.05); }
|
75% { transform: rotate(8deg) scale(1.05); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes photoFlash {
|
||||||
|
0%, 100% { transform: scale(1); filter: brightness(1); }
|
||||||
|
50% { transform: scale(1.1); filter: brightness(1.3); }
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes tubeBubble {
|
@keyframes tubeBubble {
|
||||||
0%, 100% { transform: translateY(0) scale(1); }
|
0%, 100% { transform: translateY(0) scale(1); }
|
||||||
50% { transform: translateY(-2px) scale(1.1); }
|
50% { transform: translateY(-2px) scale(1.1); }
|
||||||
|
|
|
||||||
35
src/lib/types/photos.ts
Normal file
35
src/lib/types/photos.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
export interface ExifData {
|
||||||
|
camera?: string
|
||||||
|
lens?: string
|
||||||
|
focalLength?: string
|
||||||
|
aperture?: string
|
||||||
|
shutterSpeed?: string
|
||||||
|
iso?: string
|
||||||
|
dateTaken?: string
|
||||||
|
location?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Photo {
|
||||||
|
id: string
|
||||||
|
src: string
|
||||||
|
alt: string
|
||||||
|
caption?: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
exif?: ExifData
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PhotoAlbum {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
coverPhoto: Photo
|
||||||
|
photos: Photo[]
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PhotoItem = Photo | PhotoAlbum
|
||||||
|
|
||||||
|
export function isAlbum(item: PhotoItem): item is PhotoAlbum {
|
||||||
|
return 'photos' in item && Array.isArray(item.photos)
|
||||||
|
}
|
||||||
16
src/routes/photos/+page.svelte
Normal file
16
src/routes/photos/+page.svelte
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import PhotoGrid from '$components/PhotoGrid.svelte'
|
||||||
|
import type { PageData } from './$types'
|
||||||
|
|
||||||
|
const { data }: { data: PageData } = $props()
|
||||||
|
|
||||||
|
const photoItems = $derived(data.photoItems)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PhotoGrid {photoItems} />
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
:global(main) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
413
src/routes/photos/+page.ts
Normal file
413
src/routes/photos/+page.ts
Normal file
|
|
@ -0,0 +1,413 @@
|
||||||
|
import type { PageLoad } from './$types'
|
||||||
|
import type { PhotoItem } from '$lib/types/photos'
|
||||||
|
|
||||||
|
export const load: PageLoad = async () => {
|
||||||
|
// Mock data for now - in a real app this would come from an API or CMS
|
||||||
|
const photoItems: PhotoItem[] = [
|
||||||
|
{
|
||||||
|
id: 'photo-1',
|
||||||
|
src: 'https://picsum.photos/400/600?random=1',
|
||||||
|
alt: 'Mountain landscape at sunset',
|
||||||
|
caption: 'A beautiful landscape captured during golden hour',
|
||||||
|
width: 400,
|
||||||
|
height: 600,
|
||||||
|
exif: {
|
||||||
|
camera: 'Canon EOS R5',
|
||||||
|
lens: '24-70mm f/2.8',
|
||||||
|
focalLength: '35mm',
|
||||||
|
aperture: 'f/5.6',
|
||||||
|
shutterSpeed: '1/250s',
|
||||||
|
iso: '100',
|
||||||
|
dateTaken: '2024-01-15',
|
||||||
|
location: 'Yosemite National Park'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'album-1',
|
||||||
|
title: 'Tokyo Street Photography',
|
||||||
|
description: 'A collection of street photography from Tokyo',
|
||||||
|
coverPhoto: {
|
||||||
|
id: 'album-1-cover',
|
||||||
|
src: 'https://picsum.photos/500/400?random=2',
|
||||||
|
alt: 'Tokyo street scene',
|
||||||
|
width: 500,
|
||||||
|
height: 400
|
||||||
|
},
|
||||||
|
photos: [
|
||||||
|
{
|
||||||
|
id: 'album-1-1',
|
||||||
|
src: 'https://picsum.photos/500/400?random=2',
|
||||||
|
alt: 'Tokyo street scene',
|
||||||
|
caption: 'Busy intersection in Shibuya',
|
||||||
|
width: 500,
|
||||||
|
height: 400,
|
||||||
|
exif: {
|
||||||
|
camera: 'Fujifilm X-T4',
|
||||||
|
lens: '23mm f/1.4',
|
||||||
|
focalLength: '23mm',
|
||||||
|
aperture: 'f/2.8',
|
||||||
|
shutterSpeed: '1/60s',
|
||||||
|
iso: '800',
|
||||||
|
dateTaken: '2024-02-10'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'album-1-2',
|
||||||
|
src: 'https://picsum.photos/600/400?random=25',
|
||||||
|
alt: 'Tokyo alley',
|
||||||
|
caption: 'Quiet alley in Shinjuku',
|
||||||
|
width: 600,
|
||||||
|
height: 400
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'album-1-3',
|
||||||
|
src: 'https://picsum.photos/500/700?random=26',
|
||||||
|
alt: 'Neon signs',
|
||||||
|
caption: 'Colorful neon signs in Harajuku',
|
||||||
|
width: 500,
|
||||||
|
height: 700,
|
||||||
|
exif: {
|
||||||
|
camera: 'Fujifilm X-T4',
|
||||||
|
lens: '56mm f/1.2',
|
||||||
|
focalLength: '56mm',
|
||||||
|
aperture: 'f/1.8',
|
||||||
|
shutterSpeed: '1/30s',
|
||||||
|
iso: '1600',
|
||||||
|
dateTaken: '2024-02-11'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
createdAt: '2024-02-10'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'photo-2',
|
||||||
|
src: 'https://picsum.photos/300/500?random=4',
|
||||||
|
alt: 'Modern building',
|
||||||
|
caption: 'Urban architecture study',
|
||||||
|
width: 300,
|
||||||
|
height: 500,
|
||||||
|
exif: {
|
||||||
|
camera: 'Sony A7IV',
|
||||||
|
lens: '85mm f/1.8',
|
||||||
|
focalLength: '85mm',
|
||||||
|
aperture: 'f/2.8',
|
||||||
|
shutterSpeed: '1/125s',
|
||||||
|
iso: '200',
|
||||||
|
dateTaken: '2024-01-20'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'photo-3',
|
||||||
|
src: 'https://picsum.photos/600/300?random=5',
|
||||||
|
alt: 'Ocean waves',
|
||||||
|
caption: 'Minimalist seascape composition',
|
||||||
|
width: 600,
|
||||||
|
height: 300
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'photo-4',
|
||||||
|
src: 'https://picsum.photos/400/500?random=6',
|
||||||
|
alt: 'Forest path',
|
||||||
|
caption: 'Morning light through the trees',
|
||||||
|
width: 400,
|
||||||
|
height: 500,
|
||||||
|
exif: {
|
||||||
|
camera: 'Nikon Z6II',
|
||||||
|
lens: '24-120mm f/4',
|
||||||
|
focalLength: '50mm',
|
||||||
|
aperture: 'f/8',
|
||||||
|
shutterSpeed: '1/60s',
|
||||||
|
iso: '400',
|
||||||
|
dateTaken: '2024-03-05',
|
||||||
|
location: 'Redwood National Park'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'album-2',
|
||||||
|
title: 'Portrait Series',
|
||||||
|
description: 'A collection of environmental portraits',
|
||||||
|
coverPhoto: {
|
||||||
|
id: 'album-2-cover',
|
||||||
|
src: 'https://picsum.photos/400/600?random=7',
|
||||||
|
alt: 'Portrait of a musician',
|
||||||
|
width: 400,
|
||||||
|
height: 600
|
||||||
|
},
|
||||||
|
photos: [
|
||||||
|
{
|
||||||
|
id: 'album-2-1',
|
||||||
|
src: 'https://picsum.photos/400/600?random=7',
|
||||||
|
alt: 'Portrait of a musician',
|
||||||
|
caption: 'Jazz musician in his studio',
|
||||||
|
width: 400,
|
||||||
|
height: 600,
|
||||||
|
exif: {
|
||||||
|
camera: 'Canon EOS R6',
|
||||||
|
lens: '85mm f/1.2',
|
||||||
|
focalLength: '85mm',
|
||||||
|
aperture: 'f/1.8',
|
||||||
|
shutterSpeed: '1/125s',
|
||||||
|
iso: '640',
|
||||||
|
dateTaken: '2024-02-20'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'album-2-2',
|
||||||
|
src: 'https://picsum.photos/500/650?random=27',
|
||||||
|
alt: 'Artist in gallery',
|
||||||
|
caption: 'Painter surrounded by her work',
|
||||||
|
width: 500,
|
||||||
|
height: 650
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'album-2-3',
|
||||||
|
src: 'https://picsum.photos/450/600?random=28',
|
||||||
|
alt: 'Chef in kitchen',
|
||||||
|
caption: 'Chef preparing for evening service',
|
||||||
|
width: 450,
|
||||||
|
height: 600
|
||||||
|
}
|
||||||
|
],
|
||||||
|
createdAt: '2024-02-20'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'photo-5',
|
||||||
|
src: 'https://picsum.photos/500/350?random=8',
|
||||||
|
alt: 'City skyline',
|
||||||
|
caption: 'Downtown at blue hour',
|
||||||
|
width: 500,
|
||||||
|
height: 350,
|
||||||
|
exif: {
|
||||||
|
camera: 'Sony A7R V',
|
||||||
|
lens: '16-35mm f/2.8',
|
||||||
|
focalLength: '24mm',
|
||||||
|
aperture: 'f/11',
|
||||||
|
shutterSpeed: '8s',
|
||||||
|
iso: '100',
|
||||||
|
dateTaken: '2024-01-30',
|
||||||
|
location: 'San Francisco'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'photo-6',
|
||||||
|
src: 'https://picsum.photos/350/550?random=9',
|
||||||
|
alt: 'Vintage motorcycle',
|
||||||
|
caption: 'Classic bike restoration project',
|
||||||
|
width: 350,
|
||||||
|
height: 550
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'photo-7',
|
||||||
|
src: 'https://picsum.photos/450/300?random=10',
|
||||||
|
alt: 'Coffee and books',
|
||||||
|
caption: 'Quiet morning ritual',
|
||||||
|
width: 450,
|
||||||
|
height: 300,
|
||||||
|
exif: {
|
||||||
|
camera: 'Fujifilm X100V',
|
||||||
|
lens: '23mm f/2',
|
||||||
|
focalLength: '23mm',
|
||||||
|
aperture: 'f/2.8',
|
||||||
|
shutterSpeed: '1/60s',
|
||||||
|
iso: '320',
|
||||||
|
dateTaken: '2024-03-01'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'album-3',
|
||||||
|
title: 'Nature Macro',
|
||||||
|
description: 'Close-up studies of natural details',
|
||||||
|
coverPhoto: {
|
||||||
|
id: 'album-3-cover',
|
||||||
|
src: 'https://picsum.photos/400/400?random=11',
|
||||||
|
alt: 'Dewdrop on leaf',
|
||||||
|
width: 400,
|
||||||
|
height: 400
|
||||||
|
},
|
||||||
|
photos: [
|
||||||
|
{
|
||||||
|
id: 'album-3-1',
|
||||||
|
src: 'https://picsum.photos/400/400?random=11',
|
||||||
|
alt: 'Dewdrop on leaf',
|
||||||
|
caption: 'Morning dew captured with macro lens',
|
||||||
|
width: 400,
|
||||||
|
height: 400,
|
||||||
|
exif: {
|
||||||
|
camera: 'Canon EOS R5',
|
||||||
|
lens: '100mm f/2.8 Macro',
|
||||||
|
focalLength: '100mm',
|
||||||
|
aperture: 'f/5.6',
|
||||||
|
shutterSpeed: '1/250s',
|
||||||
|
iso: '200',
|
||||||
|
dateTaken: '2024-03-15'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'album-3-2',
|
||||||
|
src: 'https://picsum.photos/500/500?random=29',
|
||||||
|
alt: 'Butterfly wing detail',
|
||||||
|
caption: 'Intricate patterns on butterfly wing',
|
||||||
|
width: 500,
|
||||||
|
height: 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'album-3-3',
|
||||||
|
src: 'https://picsum.photos/400/600?random=30',
|
||||||
|
alt: 'Tree bark texture',
|
||||||
|
caption: 'Ancient oak bark patterns',
|
||||||
|
width: 400,
|
||||||
|
height: 600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'album-3-4',
|
||||||
|
src: 'https://picsum.photos/350/500?random=31',
|
||||||
|
alt: 'Flower stamen',
|
||||||
|
caption: 'Lily stamen in soft light',
|
||||||
|
width: 350,
|
||||||
|
height: 500
|
||||||
|
}
|
||||||
|
],
|
||||||
|
createdAt: '2024-03-15'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'photo-8',
|
||||||
|
src: 'https://picsum.photos/600/400?random=12',
|
||||||
|
alt: 'Desert landscape',
|
||||||
|
caption: 'Vast desert under starry sky',
|
||||||
|
width: 600,
|
||||||
|
height: 400,
|
||||||
|
exif: {
|
||||||
|
camera: 'Nikon D850',
|
||||||
|
lens: '14-24mm f/2.8',
|
||||||
|
focalLength: '14mm',
|
||||||
|
aperture: 'f/2.8',
|
||||||
|
shutterSpeed: '25s',
|
||||||
|
iso: '3200',
|
||||||
|
dateTaken: '2024-02-25',
|
||||||
|
location: 'Death Valley'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'photo-9',
|
||||||
|
src: 'https://picsum.photos/300/450?random=13',
|
||||||
|
alt: 'Vintage camera',
|
||||||
|
caption: 'My grandfather\'s Leica',
|
||||||
|
width: 300,
|
||||||
|
height: 450
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'photo-10',
|
||||||
|
src: 'https://picsum.photos/550/350?random=14',
|
||||||
|
alt: 'Market scene',
|
||||||
|
caption: 'Colorful spices at local market',
|
||||||
|
width: 550,
|
||||||
|
height: 350,
|
||||||
|
exif: {
|
||||||
|
camera: 'Fujifilm X-T5',
|
||||||
|
lens: '18-55mm f/2.8-4',
|
||||||
|
focalLength: '35mm',
|
||||||
|
aperture: 'f/4',
|
||||||
|
shutterSpeed: '1/125s',
|
||||||
|
iso: '800',
|
||||||
|
dateTaken: '2024-03-10',
|
||||||
|
location: 'Marrakech, Morocco'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'photo-11',
|
||||||
|
src: 'https://picsum.photos/400/600?random=15',
|
||||||
|
alt: 'Lighthouse at dawn',
|
||||||
|
caption: 'Coastal beacon in morning mist',
|
||||||
|
width: 400,
|
||||||
|
height: 600,
|
||||||
|
exif: {
|
||||||
|
camera: 'Sony A7III',
|
||||||
|
lens: '70-200mm f/2.8',
|
||||||
|
focalLength: '135mm',
|
||||||
|
aperture: 'f/8',
|
||||||
|
shutterSpeed: '1/200s',
|
||||||
|
iso: '400',
|
||||||
|
dateTaken: '2024-02-28',
|
||||||
|
location: 'Big Sur, California'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'photo-12',
|
||||||
|
src: 'https://picsum.photos/500/300?random=16',
|
||||||
|
alt: 'Train station',
|
||||||
|
caption: 'Rush hour commuters',
|
||||||
|
width: 500,
|
||||||
|
height: 300
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'album-4',
|
||||||
|
title: 'Black & White',
|
||||||
|
description: 'Monochrome photography collection',
|
||||||
|
coverPhoto: {
|
||||||
|
id: 'album-4-cover',
|
||||||
|
src: 'https://picsum.photos/450/600?random=17',
|
||||||
|
alt: 'Urban shadows',
|
||||||
|
width: 450,
|
||||||
|
height: 600
|
||||||
|
},
|
||||||
|
photos: [
|
||||||
|
{
|
||||||
|
id: 'album-4-1',
|
||||||
|
src: 'https://picsum.photos/450/600?random=17',
|
||||||
|
alt: 'Urban shadows',
|
||||||
|
caption: 'Dramatic shadows in the financial district',
|
||||||
|
width: 450,
|
||||||
|
height: 600,
|
||||||
|
exif: {
|
||||||
|
camera: 'Leica M11 Monochrom',
|
||||||
|
lens: '35mm f/1.4',
|
||||||
|
focalLength: '35mm',
|
||||||
|
aperture: 'f/5.6',
|
||||||
|
shutterSpeed: '1/250s',
|
||||||
|
iso: '200',
|
||||||
|
dateTaken: '2024-03-20'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'album-4-2',
|
||||||
|
src: 'https://picsum.photos/600/400?random=32',
|
||||||
|
alt: 'Elderly man reading',
|
||||||
|
caption: 'Contemplation in the park',
|
||||||
|
width: 600,
|
||||||
|
height: 400
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'album-4-3',
|
||||||
|
src: 'https://picsum.photos/400/500?random=33',
|
||||||
|
alt: 'Rain on window',
|
||||||
|
caption: 'Storm patterns on glass',
|
||||||
|
width: 400,
|
||||||
|
height: 500
|
||||||
|
}
|
||||||
|
],
|
||||||
|
createdAt: '2024-03-20'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'photo-13',
|
||||||
|
src: 'https://picsum.photos/350/500?random=18',
|
||||||
|
alt: 'Street art mural',
|
||||||
|
caption: 'Vibrant wall art in the Mission District',
|
||||||
|
width: 350,
|
||||||
|
height: 500,
|
||||||
|
exif: {
|
||||||
|
camera: 'iPhone 15 Pro',
|
||||||
|
lens: '24mm f/1.8',
|
||||||
|
focalLength: '24mm',
|
||||||
|
aperture: 'f/1.8',
|
||||||
|
shutterSpeed: '1/120s',
|
||||||
|
iso: '64',
|
||||||
|
dateTaken: '2024-03-25',
|
||||||
|
location: 'San Francisco'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
photoItems
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue