423 lines
9.4 KiB
Text
423 lines
9.4 KiB
Text
---
|
||
import { Image } from "astro:assets";
|
||
import type { ImageMetadata } from "astro";
|
||
import swipeIcon from "@/assets/icons/swipe.png";
|
||
|
||
type PhotoSidecar = {
|
||
id: string;
|
||
title: string[];
|
||
alt: string;
|
||
location: string;
|
||
date: string;
|
||
tags: string[];
|
||
exif: {
|
||
camera: string;
|
||
lens: string;
|
||
aperture: string;
|
||
iso: string;
|
||
focal_length: string;
|
||
shutter_speed: string;
|
||
};
|
||
};
|
||
|
||
interface Props {
|
||
sidecar: PhotoSidecar;
|
||
image: ImageMetadata;
|
||
prevHref: string | null;
|
||
nextHref: string | null;
|
||
backHref: string;
|
||
backLabel: string;
|
||
canonicalUrl: string;
|
||
}
|
||
|
||
const {
|
||
sidecar,
|
||
image,
|
||
prevHref,
|
||
nextHref,
|
||
backHref,
|
||
backLabel,
|
||
canonicalUrl,
|
||
} = Astro.props;
|
||
---
|
||
|
||
<main class="viewer h-entry">
|
||
<a href={canonicalUrl} class="u-url" aria-hidden="true" tabindex="-1" style="display:none">Permalink</a>
|
||
<span class="p-author h-card" style="display:none"><img class="u-photo" src="https://adrian-altner.com/avatar.jpg" alt="Adrian Altner" /><span class="p-name">Adrian Altner</span><a class="u-url" href="https://adrian-altner.com">adrian-altner.com</a></span>
|
||
|
||
<header class="topbar">
|
||
<a href={backHref} class="back-btn">← {backLabel}</a>
|
||
</header>
|
||
|
||
<div class="photo-area">
|
||
<figure class="figure">
|
||
<div class="photo-frame">
|
||
{
|
||
prevHref ? (
|
||
<a href={prevHref} class="arrow arrow--prev" aria-label="Previous photo">‹</a>
|
||
) : (
|
||
<span class="arrow arrow--prev arrow--disabled" aria-hidden="true">‹</span>
|
||
)
|
||
}
|
||
<div class="photo-wrap">
|
||
<Image
|
||
src={image}
|
||
alt={sidecar.alt}
|
||
width={image.width}
|
||
height={image.height}
|
||
class="photo u-photo"
|
||
/>
|
||
<button class="info-btn" aria-label="Photo info" aria-expanded="false">ⓘ</button>
|
||
<div class="infobar" role="region" aria-label="Photo info">
|
||
<div class="infobar__primary">
|
||
<h1 class="title p-name">{sidecar.title[0]}</h1>
|
||
<time class="date dt-published" datetime={new Date(sidecar.date).toISOString()}>{sidecar.date}</time>
|
||
</div>
|
||
<div class="infobar__secondary">
|
||
<span class="exif-line">ƒ{sidecar.exif.aperture} · {sidecar.exif.shutter_speed}s · ISO {sidecar.exif.iso} · {sidecar.exif.focal_length}mm</span>
|
||
{
|
||
sidecar.tags.length > 0 && (
|
||
<div class="tags">
|
||
{sidecar.tags.map((tag) => (
|
||
<a
|
||
href={`/photos/tags/${tag.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "")}`}
|
||
class="tag"
|
||
>
|
||
{tag}
|
||
</a>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{
|
||
nextHref ? (
|
||
<a href={nextHref} class="arrow arrow--next" aria-label="Next photo">›</a>
|
||
) : (
|
||
<span class="arrow arrow--next arrow--disabled" aria-hidden="true">›</span>
|
||
)
|
||
}
|
||
</div>
|
||
</figure>
|
||
</div>
|
||
|
||
<div class="bottombar" aria-hidden="true">
|
||
<Image src={swipeIcon} alt="" class="swipe-hint" width={28} height={28} />
|
||
</div>
|
||
</main>
|
||
|
||
<script>
|
||
const prev = document.querySelector<HTMLAnchorElement>('.arrow--prev[href]');
|
||
const next = document.querySelector<HTMLAnchorElement>('.arrow--next[href]');
|
||
const infoBtn = document.querySelector<HTMLButtonElement>('.info-btn');
|
||
const infobar = document.querySelector<HTMLElement>('.infobar');
|
||
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'ArrowLeft' && prev) {
|
||
e.preventDefault();
|
||
prev.click();
|
||
} else if (e.key === 'ArrowRight' && next) {
|
||
e.preventDefault();
|
||
next.click();
|
||
} else if (e.key === 'Escape') {
|
||
closeInfo();
|
||
}
|
||
});
|
||
|
||
let touchStartX = 0;
|
||
document.addEventListener('touchstart', (e) => {
|
||
touchStartX = e.touches[0]!.clientX;
|
||
}, { passive: true });
|
||
|
||
document.addEventListener('touchend', (e) => {
|
||
const dx = e.changedTouches[0]!.clientX - touchStartX;
|
||
if (Math.abs(dx) < 40) return;
|
||
if (dx > 0 && prev) prev.click();
|
||
else if (dx < 0 && next) next.click();
|
||
}, { passive: true });
|
||
|
||
function openInfo() {
|
||
infobar?.classList.add('is-open');
|
||
infoBtn?.setAttribute('aria-expanded', 'true');
|
||
}
|
||
|
||
function closeInfo() {
|
||
infobar?.classList.remove('is-open');
|
||
infoBtn?.setAttribute('aria-expanded', 'false');
|
||
}
|
||
|
||
infoBtn?.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
infobar?.classList.contains('is-open') ? closeInfo() : openInfo();
|
||
});
|
||
|
||
document.querySelector('.photo-area')?.addEventListener('click', (e) => {
|
||
if (infobar?.classList.contains('is-open') && !(e.target as Element).closest('.infobar')) {
|
||
closeInfo();
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<style>
|
||
.viewer {
|
||
display: grid;
|
||
grid-template-rows: auto 1fr auto;
|
||
height: 100dvh;
|
||
background: #111;
|
||
color: #f5f5f3;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
|
||
.topbar {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0.6rem 1.25rem;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
@keyframes swipe-reveal {
|
||
0% { opacity: 0; transform: translateX(0); }
|
||
10% { opacity: 0.75; transform: translateX(0); }
|
||
35% { opacity: 0.75; transform: translateX(-28px); }
|
||
65% { opacity: 0.75; transform: translateX(28px); }
|
||
90% { opacity: 0.75; transform: translateX(0); }
|
||
100% { opacity: 0; transform: translateX(0); }
|
||
}
|
||
|
||
.swipe-hint {
|
||
display: none;
|
||
width: 28px;
|
||
height: 28px;
|
||
filter: invert(1);
|
||
opacity: 0;
|
||
}
|
||
|
||
.back-btn {
|
||
font-size: var(--text-xs);
|
||
color: #888;
|
||
text-decoration: none;
|
||
transition: color 0.2s;
|
||
}
|
||
|
||
.back-btn:hover {
|
||
color: #f5f5f3;
|
||
}
|
||
|
||
.bottombar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 0.6rem 1.25rem;
|
||
font-size: var(--text-xs);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
.bottombar {
|
||
padding-top: 2rem;
|
||
padding-bottom: 2.5rem;
|
||
}
|
||
|
||
.photo {
|
||
max-height: calc(100dvh - 120px);
|
||
}
|
||
|
||
.swipe-hint {
|
||
display: block;
|
||
animation: swipe-reveal 5s ease forwards;
|
||
animation-delay: 0.5s;
|
||
}
|
||
}
|
||
|
||
|
||
.photo-area {
|
||
display: grid;
|
||
grid-template-columns: 1fr;
|
||
align-items: center;
|
||
min-height: 0;
|
||
position: relative;
|
||
}
|
||
|
||
.figure {
|
||
margin: 0;
|
||
min-width: 0;
|
||
min-height: 0;
|
||
align-self: stretch;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.photo-frame {
|
||
display: flex;
|
||
width: max-content;
|
||
max-width: 100%;
|
||
align-items: center;
|
||
}
|
||
|
||
.photo-wrap {
|
||
position: relative;
|
||
overflow: hidden;
|
||
min-width: 0;
|
||
display: flex;
|
||
}
|
||
|
||
.photo {
|
||
display: block;
|
||
/* topbar + bottombar = 96px */
|
||
max-height: calc(100dvh - 96px);
|
||
max-width: 100%;
|
||
width: auto;
|
||
height: auto;
|
||
object-fit: contain;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.arrow {
|
||
font-size: 2.5rem;
|
||
line-height: 1;
|
||
color: #fff;
|
||
opacity: 0.45;
|
||
text-decoration: none;
|
||
padding: 1rem 1.25rem;
|
||
transition: opacity 0.2s;
|
||
user-select: none;
|
||
-webkit-tap-highlight-color: transparent;
|
||
display: flex;
|
||
align-items: center;
|
||
align-self: stretch;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.arrow:not(.arrow--disabled):hover {
|
||
opacity: 1;
|
||
}
|
||
|
||
.arrow--disabled {
|
||
opacity: 0.1;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.info-btn {
|
||
position: absolute;
|
||
bottom: 0.75rem;
|
||
right: 0.75rem;
|
||
background: none;
|
||
border: none;
|
||
color: #fff;
|
||
opacity: 0.45;
|
||
font-size: 1.25rem;
|
||
cursor: pointer;
|
||
padding: 0.25rem;
|
||
line-height: 1;
|
||
transition: opacity 0.2s;
|
||
-webkit-tap-highlight-color: transparent;
|
||
z-index: 2;
|
||
}
|
||
|
||
.info-btn:hover,
|
||
.info-btn[aria-expanded="true"] {
|
||
opacity: 1;
|
||
}
|
||
|
||
.infobar {
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: baseline;
|
||
gap: 0.5rem 1.5rem;
|
||
padding: 2rem 1.25rem 1rem;
|
||
background: linear-gradient(to top, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0) 100%);
|
||
transform: translateY(100%);
|
||
transition: transform 0.25s ease;
|
||
z-index: 1;
|
||
text-shadow: 0 1px 4px rgba(0,0,0,0.8);
|
||
}
|
||
|
||
.infobar.is-open {
|
||
transform: translateY(0);
|
||
}
|
||
|
||
.infobar__primary {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 0.75rem;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.title {
|
||
font-size: var(--text-sm);
|
||
font-weight: 400;
|
||
margin: 0;
|
||
}
|
||
|
||
.date {
|
||
font-size: var(--text-xs);
|
||
color: #f5f5f3;
|
||
}
|
||
|
||
.infobar__secondary {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 0.5rem 1rem;
|
||
}
|
||
|
||
.exif-line {
|
||
font-size: var(--text-xs);
|
||
color: #f5f5f3;
|
||
letter-spacing: 0.01em;
|
||
}
|
||
|
||
.tags {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.4rem;
|
||
}
|
||
|
||
.tag {
|
||
font-size: var(--text-xs);
|
||
color: #f5f5f3;
|
||
border: 1px solid rgba(255,255,255,0.3);
|
||
border-radius: 3px;
|
||
padding: 0.15rem 0.5rem;
|
||
text-decoration: none;
|
||
transition: color 0.2s, border-color 0.2s;
|
||
}
|
||
|
||
.tag:hover {
|
||
color: #f5f5f3;
|
||
border-color: #555;
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
.photo-frame {
|
||
width: 100%;
|
||
}
|
||
|
||
.photo-wrap {
|
||
width: 100%;
|
||
}
|
||
|
||
.arrow {
|
||
display: none;
|
||
}
|
||
|
||
.infobar {
|
||
flex-direction: column;
|
||
gap: 0.35rem;
|
||
padding: 0.5rem 1rem 1rem;
|
||
}
|
||
|
||
.infobar__secondary {
|
||
gap: 0.4rem 0.75rem;
|
||
}
|
||
}
|
||
</style>
|