adrian-altner.com/src/components/PhotoDetail.astro
2026-03-30 14:16:43 +02:00

423 lines
9.4 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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>