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 LabsIcon from '$icons/labs.svg'
|
||||
import UniverseIcon from '$icons/universe.svg'
|
||||
import PhotosIcon from '$icons/photos.svg'
|
||||
import { page } from '$app/stores'
|
||||
|
||||
const currentPath = $derived($page.url.pathname)
|
||||
|
|
@ -10,11 +11,12 @@
|
|||
icon: typeof WorkIcon
|
||||
text: string
|
||||
href: string
|
||||
variant: 'work' | 'universe' | 'labs'
|
||||
variant: 'work' | 'universe' | 'labs' | 'photos'
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ icon: WorkIcon, text: 'Work', href: '/', variant: 'work' },
|
||||
{ icon: PhotosIcon, text: 'Photos', href: '/photos', variant: 'photos' },
|
||||
{ icon: LabsIcon, text: 'Labs', href: '#', variant: 'labs' },
|
||||
{ icon: UniverseIcon, text: 'Universe', href: '/universe', variant: 'universe' }
|
||||
]
|
||||
|
|
@ -25,7 +27,8 @@
|
|||
// Calculate active index based on current path
|
||||
const activeIndex = $derived(
|
||||
currentPath === '/' ? 0 :
|
||||
currentPath.startsWith('/universe') ? 2 :
|
||||
currentPath.startsWith('/photos') ? 1 :
|
||||
currentPath.startsWith('/universe') ? 3 :
|
||||
-1
|
||||
)
|
||||
|
||||
|
|
@ -64,6 +67,7 @@
|
|||
function getBgColor(variant: string): string {
|
||||
switch (variant) {
|
||||
case 'work': return '#ffcdc5' // $work-bg
|
||||
case 'photos': return '#e8c5ff' // $photos-bg (purple)
|
||||
case 'universe': return '#ffebc5' // $universe-bg
|
||||
case 'labs': return '#c5eaff' // $labs-bg
|
||||
default: return '#c5eaff'
|
||||
|
|
@ -74,6 +78,7 @@
|
|||
function getTextColor(variant: string): string {
|
||||
switch (variant) {
|
||||
case 'work': return '#d0290d' // $work-color
|
||||
case 'photos': return '#7c3aed' // $photos-color (purple)
|
||||
case 'universe': return '#b97d14' // $universe-color
|
||||
case 'labs': return '#1482c1' // $labs-color
|
||||
default: return '#1482c1'
|
||||
|
|
@ -157,18 +162,23 @@
|
|||
}
|
||||
|
||||
// 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) {
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
// Third item is Universe (index 3)
|
||||
.nav-item:nth-of-type(3) :global(svg.animate) {
|
||||
// Fourth item is Universe (index 4)
|
||||
.nav-item:nth-of-type(4) :global(svg.animate) {
|
||||
animation: starSpin 0.6s ease;
|
||||
}
|
||||
|
||||
|
|
@ -178,6 +188,11 @@
|
|||
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 {
|
||||
0%, 100% { transform: translateY(0) scale(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