refactor: remove 5 unused components
- Remove Squiggly.svelte - Remove PhotoLightbox.svelte - Remove Pill.svelte - Remove SVGHoverEffect.svelte - Remove MusicPreview.svelte These components were identified as unused in the codebase analysis. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
d4caad10a3
commit
ddfe1cac8f
6 changed files with 6 additions and 971 deletions
|
|
@ -56,12 +56,12 @@ This PRD outlines a comprehensive cleanup and refactoring plan for the jedmund-s
|
||||||
### Phase 1: Quick Wins (Week 1)
|
### Phase 1: Quick Wins (Week 1)
|
||||||
Focus on low-risk, high-impact changes that don't require architectural modifications.
|
Focus on low-risk, high-impact changes that don't require architectural modifications.
|
||||||
|
|
||||||
- [ ] **Remove unused components** (5 components)
|
- [x] **Remove unused components** (5 components)
|
||||||
- [ ] Delete `/src/lib/components/Squiggly.svelte`
|
- [x] Delete `/src/lib/components/Squiggly.svelte`
|
||||||
- [ ] Delete `/src/lib/components/PhotoLightbox.svelte`
|
- [x] Delete `/src/lib/components/PhotoLightbox.svelte`
|
||||||
- [ ] Delete `/src/lib/components/Pill.svelte`
|
- [x] Delete `/src/lib/components/Pill.svelte`
|
||||||
- [ ] Delete `/src/lib/components/SVGHoverEffect.svelte`
|
- [x] Delete `/src/lib/components/SVGHoverEffect.svelte`
|
||||||
- [ ] Delete `/src/lib/components/MusicPreview.svelte`
|
- [x] Delete `/src/lib/components/MusicPreview.svelte`
|
||||||
|
|
||||||
- [ ] **Remove unused SVG files** (13 files)
|
- [ ] **Remove unused SVG files** (13 files)
|
||||||
- [ ] Delete unused icons: `close.svg`, `music.svg`
|
- [ ] Delete unused icons: `close.svg`, `music.svg`
|
||||||
|
|
|
||||||
|
|
@ -1,350 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onMount, onDestroy } from 'svelte'
|
|
||||||
import { fade } from 'svelte/transition'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
previewUrl: string
|
|
||||||
albumName?: string
|
|
||||||
artistName?: string
|
|
||||||
onPlayStateChange?: (isPlaying: boolean) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
let { previewUrl, albumName = '', artistName = '', onPlayStateChange }: Props = $props()
|
|
||||||
|
|
||||||
let audio: HTMLAudioElement | null = $state(null)
|
|
||||||
let isPlaying = $state(false)
|
|
||||||
let isLoading = $state(false)
|
|
||||||
let currentTime = $state(0)
|
|
||||||
let duration = $state(30) // Apple Music previews are 30 seconds
|
|
||||||
let volume = $state(1)
|
|
||||||
let hasError = $state(false)
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (audio) {
|
|
||||||
audio.volume = volume
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
onPlayStateChange?.(isPlaying)
|
|
||||||
})
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
// Listen for other audio elements playing
|
|
||||||
const handleAudioPlay = (e: Event) => {
|
|
||||||
const playingAudio = e.target as HTMLAudioElement
|
|
||||||
if (playingAudio !== audio && audio && !audio.paused) {
|
|
||||||
pause()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('play', handleAudioPlay, true)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('play', handleAudioPlay, true)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
if (audio) {
|
|
||||||
audio.pause()
|
|
||||||
audio = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function togglePlayPause() {
|
|
||||||
if (isPlaying) {
|
|
||||||
pause()
|
|
||||||
} else {
|
|
||||||
play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function play() {
|
|
||||||
if (!audio || hasError) return
|
|
||||||
|
|
||||||
isLoading = true
|
|
||||||
try {
|
|
||||||
await audio.play()
|
|
||||||
isPlaying = true
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to play preview:', error)
|
|
||||||
hasError = true
|
|
||||||
} finally {
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function pause() {
|
|
||||||
if (!audio) return
|
|
||||||
audio.pause()
|
|
||||||
isPlaying = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTimeUpdate() {
|
|
||||||
if (audio) {
|
|
||||||
currentTime = audio.currentTime
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEnded() {
|
|
||||||
isPlaying = false
|
|
||||||
currentTime = 0
|
|
||||||
if (audio) {
|
|
||||||
audio.currentTime = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleError() {
|
|
||||||
hasError = true
|
|
||||||
isPlaying = false
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleLoadedMetadata() {
|
|
||||||
if (audio) {
|
|
||||||
duration = audio.duration
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function seek(e: MouseEvent) {
|
|
||||||
const target = e.currentTarget as HTMLElement
|
|
||||||
const rect = target.getBoundingClientRect()
|
|
||||||
const x = e.clientX - rect.left
|
|
||||||
const percentage = x / rect.width
|
|
||||||
const newTime = percentage * duration
|
|
||||||
|
|
||||||
if (audio) {
|
|
||||||
audio.currentTime = newTime
|
|
||||||
currentTime = newTime
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
|
||||||
if (e.code === 'Space') {
|
|
||||||
e.preventDefault()
|
|
||||||
togglePlayPause()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(time: number): string {
|
|
||||||
const minutes = Math.floor(time / 60)
|
|
||||||
const seconds = Math.floor(time % 60)
|
|
||||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const progressPercentage = $derived((currentTime / duration) * 100)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="music-preview" role="region" aria-label="Music preview player">
|
|
||||||
<audio
|
|
||||||
bind:this={audio}
|
|
||||||
src={previewUrl}
|
|
||||||
onloadedmetadata={handleLoadedMetadata}
|
|
||||||
ontimeupdate={handleTimeUpdate}
|
|
||||||
onended={handleEnded}
|
|
||||||
onerror={handleError}
|
|
||||||
preload="metadata"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="controls">
|
|
||||||
<button
|
|
||||||
class="play-button"
|
|
||||||
onclick={togglePlayPause}
|
|
||||||
onkeydown={handleKeydown}
|
|
||||||
disabled={hasError || isLoading}
|
|
||||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
|
||||||
aria-pressed={isPlaying}
|
|
||||||
>
|
|
||||||
{#if isLoading}
|
|
||||||
<span class="loading-spinner" aria-hidden="true">⟳</span>
|
|
||||||
{:else if hasError}
|
|
||||||
<span aria-hidden="true">⚠</span>
|
|
||||||
{:else if isPlaying}
|
|
||||||
<span aria-hidden="true">❚❚</span>
|
|
||||||
{:else}
|
|
||||||
<span aria-hidden="true">▶</span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="progress-container">
|
|
||||||
<div
|
|
||||||
class="progress-bar"
|
|
||||||
onclick={seek}
|
|
||||||
role="slider"
|
|
||||||
aria-label="Seek"
|
|
||||||
aria-valuemin={0}
|
|
||||||
aria-valuemax={duration}
|
|
||||||
aria-valuenow={currentTime}
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
<div class="progress-fill" style="width: {progressPercentage}%"></div>
|
|
||||||
</div>
|
|
||||||
<div class="time-display">
|
|
||||||
<span>{formatTime(currentTime)}</span>
|
|
||||||
<span>/</span>
|
|
||||||
<span>{formatTime(duration)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="volume-control">
|
|
||||||
<label for="volume" class="visually-hidden">Volume</label>
|
|
||||||
<input
|
|
||||||
id="volume"
|
|
||||||
type="range"
|
|
||||||
bind:value={volume}
|
|
||||||
min="0"
|
|
||||||
max="1"
|
|
||||||
step="0.1"
|
|
||||||
aria-label="Volume control"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if hasError}
|
|
||||||
<p class="error-message" transition:fade={{ duration: 200 }}>Preview unavailable</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.music-preview {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
padding: var(--spacing-sm);
|
|
||||||
background: var(--color-surface);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-button {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
padding: 0;
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: var(--color-primary-contrast);
|
|
||||||
border: none;
|
|
||||||
border-radius: 50%;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
transform: scale(1.05);
|
|
||||||
background: var(--color-primary-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-spinner {
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-container {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-2xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar {
|
|
||||||
position: relative;
|
|
||||||
height: 6px;
|
|
||||||
background: var(--color-surface-secondary);
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&:hover .progress-fill {
|
|
||||||
background: var(--color-primary-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-fill {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
height: 100%;
|
|
||||||
background: var(--color-primary);
|
|
||||||
transition: background 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-display {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-2xs);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.volume-control {
|
|
||||||
width: 80px;
|
|
||||||
|
|
||||||
input[type='range'] {
|
|
||||||
width: 100%;
|
|
||||||
height: 6px;
|
|
||||||
background: var(--color-surface-secondary);
|
|
||||||
border-radius: 3px;
|
|
||||||
outline: none;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
|
|
||||||
&::-webkit-slider-thumb {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
background: var(--color-primary);
|
|
||||||
border-radius: 50%;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-moz-range-thumb {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
background: var(--color-primary);
|
|
||||||
border-radius: 50%;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message {
|
|
||||||
margin: 0;
|
|
||||||
padding: var(--spacing-xs);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--color-error);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visually-hidden {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
padding: 0;
|
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0, 0, 0, 0);
|
|
||||||
white-space: nowrap;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,376 +0,0 @@
|
||||||
<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: 1400;
|
|
||||||
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>
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { ComponentType } from 'svelte'
|
|
||||||
|
|
||||||
let {
|
|
||||||
icon,
|
|
||||||
text,
|
|
||||||
href,
|
|
||||||
active = false,
|
|
||||||
variant = 'default'
|
|
||||||
}: {
|
|
||||||
icon: ComponentType
|
|
||||||
text: string
|
|
||||||
href?: string
|
|
||||||
active?: boolean
|
|
||||||
variant?: 'work' | 'universe' | 'default'
|
|
||||||
} = $props()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<a {href} class="pill {variant}" class:active>
|
|
||||||
<icon />
|
|
||||||
<span>{text}</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.pill {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 100px;
|
|
||||||
text-decoration: none;
|
|
||||||
color: $grey-20; // #666
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 400;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
:global(svg) {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
fill: currentColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Work variant
|
|
||||||
&.work {
|
|
||||||
&:hover,
|
|
||||||
&.active {
|
|
||||||
background: $work-bg;
|
|
||||||
color: $work-color;
|
|
||||||
|
|
||||||
:global(svg) {
|
|
||||||
fill: $work-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Universe variant
|
|
||||||
&.universe {
|
|
||||||
&:hover,
|
|
||||||
&.active {
|
|
||||||
background: $universe-bg;
|
|
||||||
color: $universe-color;
|
|
||||||
|
|
||||||
:global(svg) {
|
|
||||||
fill: $universe-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default variant (Labs)
|
|
||||||
&.default {
|
|
||||||
&:hover,
|
|
||||||
&.active {
|
|
||||||
background: $labs-bg;
|
|
||||||
color: $labs-color;
|
|
||||||
|
|
||||||
:global(svg) {
|
|
||||||
fill: $labs-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
<script>
|
|
||||||
import { onMount } from 'svelte'
|
|
||||||
import { spring } from 'svelte/motion'
|
|
||||||
|
|
||||||
const {
|
|
||||||
SVGComponent,
|
|
||||||
backgroundColor = '#f0f0f0',
|
|
||||||
maxMovement = 20,
|
|
||||||
containerHeight = '300px',
|
|
||||||
stiffness = 0.15,
|
|
||||||
damping = 0.8
|
|
||||||
} = $props()
|
|
||||||
|
|
||||||
let container = $state(null)
|
|
||||||
let svg = $state(null)
|
|
||||||
|
|
||||||
const position = spring(
|
|
||||||
{ x: 0, y: 0 },
|
|
||||||
{
|
|
||||||
stiffness,
|
|
||||||
damping
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (svg) {
|
|
||||||
const { x, y } = $position
|
|
||||||
svg.style.transform = `translate(calc(-50% + ${x}px), calc(-50% + ${y}px))`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function handleMouseMove(event) {
|
|
||||||
const rect = container.getBoundingClientRect()
|
|
||||||
const x = event.clientX - rect.left
|
|
||||||
const y = event.clientY - rect.top
|
|
||||||
|
|
||||||
position.set({
|
|
||||||
x: (x / rect.width - 0.5) * 2 * maxMovement,
|
|
||||||
y: (y / rect.height - 0.5) * 2 * maxMovement
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMouseLeave() {
|
|
||||||
position.set({ x: 0, y: 0 })
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
svg = container.querySelector('svg')
|
|
||||||
if (svg) {
|
|
||||||
svg.style.position = 'absolute'
|
|
||||||
svg.style.left = '50%'
|
|
||||||
svg.style.top = '50%'
|
|
||||||
svg.style.transform = 'translate(-50%, -50%)'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
bind:this={container}
|
|
||||||
on:mousemove={handleMouseMove}
|
|
||||||
on:mouseleave={handleMouseLeave}
|
|
||||||
style="position: relative; overflow: hidden; background-color: {backgroundColor}; height: {containerHeight}; display: flex; justify-content: center; align-items: center;"
|
|
||||||
>
|
|
||||||
<div style="position: relative; width: 100%; height: 100%;">
|
|
||||||
<SVGComponent />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
div {
|
|
||||||
border-radius: $corner-radius;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte'
|
|
||||||
|
|
||||||
export let text = 'Hello, Squiggly World!'
|
|
||||||
export let frequency = 0.4
|
|
||||||
export let amplitude = 1.5
|
|
||||||
export let color = '#e33d3d'
|
|
||||||
export let distance = 3
|
|
||||||
export let lineWidth = 1.75
|
|
||||||
|
|
||||||
let textWidth = 0
|
|
||||||
let textElement: HTMLHeadingElement
|
|
||||||
let squigglyHeight: number
|
|
||||||
|
|
||||||
$: path = generatePath(textWidth, frequency, amplitude, distance)
|
|
||||||
$: squigglyHeight = distance + amplitude * 2 + lineWidth
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
updateTextWidth()
|
|
||||||
})
|
|
||||||
|
|
||||||
function updateTextWidth(): void {
|
|
||||||
textWidth = textElement?.getBoundingClientRect().width || 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function generatePath(width: number, freq: number, amp: number, dist: number): string {
|
|
||||||
if (width === 0) return ''
|
|
||||||
const startX = 2
|
|
||||||
const endX = width - 2
|
|
||||||
const startY = amp * Math.sin(startX * freq) + dist
|
|
||||||
|
|
||||||
let pathData = `M${startX},${startY} `
|
|
||||||
|
|
||||||
for (let x = startX; x <= endX; x++) {
|
|
||||||
const y = amp * Math.sin(x * freq) + dist
|
|
||||||
pathData += `L${x},${y} `
|
|
||||||
}
|
|
||||||
return pathData
|
|
||||||
}
|
|
||||||
|
|
||||||
$: {
|
|
||||||
text
|
|
||||||
updateTextWidth()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="squiggly-container" style="padding-bottom: {squigglyHeight}px;">
|
|
||||||
<h2 bind:this={textElement} class="squiggly-header" style="color: {color}">{text}</h2>
|
|
||||||
<svg
|
|
||||||
class="squiggly-underline"
|
|
||||||
width={textWidth}
|
|
||||||
height={squigglyHeight}
|
|
||||||
viewBox="0 0 {textWidth} {squigglyHeight}"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d={path}
|
|
||||||
fill="none"
|
|
||||||
stroke={color}
|
|
||||||
stroke-width={lineWidth}
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.squiggly-header {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: $unit-fourth;
|
|
||||||
}
|
|
||||||
|
|
||||||
.squiggly-container {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.squiggly-underline {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
bottom: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
Loading…
Reference in a new issue