Skip to content

Commit 61fc062

Browse files
authored
Merge branch 'main' into fix/seo-audit-1361-polish
2 parents 6286c74 + f38e13a commit 61fc062

12 files changed

Lines changed: 265 additions & 791 deletions

File tree

docs/PROJECT_STRUCTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ Reusable UI components organized by feature:
141141
- `FirstSection.astro` - First section component
142142
- `SecondSection.astro` - Second section component
143143
- `Video.astro` - Video component
144-
- `VideoModal.astro` - Video modal component
144+
- `MediaLightbox.astro` - Image and video modal lightbox
145145

146146
**Roadmap** (`roadmap/`)
147147

src/actions/index.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,5 @@
1-
import { vote, unvote, getUserVoted, loginWithDatum } from './roadmap';
21
import { NewsletterSignup } from './newsletter';
32

43
export const server = {
5-
vote,
6-
unvote,
7-
getUserVoted,
8-
loginWithDatum,
94
NewsletterSignup,
105
};

src/actions/roadmap.ts

Lines changed: 0 additions & 79 deletions
This file was deleted.

src/components/MarkdownLightbox.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
//
44
// Native <dialog>-based lightbox for viewing a page as raw markdown. Mirrors
55
// the agents.datum.net "TerminalLightbox" behaviour but uses the codebase's
6-
// idiomatic custom-element + <dialog> pattern (see VideoModal.astro) instead
6+
// idiomatic custom-element + <dialog> pattern (see MediaLightbox.astro) instead
77
// of React or Alpine.
88
//
99
// Triggered by any anchor with `data-markdown-trigger` attribute — its `href`

src/components/MediaLightbox.astro

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
---
2+
// src/components/MediaLightbox.astro
3+
import Icon from '@components/Icon.astro';
4+
5+
interface MediaLightboxProps {
6+
/** CSS selector for images that should open in the lightbox. */
7+
selector?: string;
8+
/**
9+
* CSS selector for video triggers. Each matching element must expose a
10+
* `data-embed-url` attribute holding a YouTube embed URL.
11+
*/
12+
videoSelector?: string;
13+
class?: string;
14+
}
15+
16+
const {
17+
selector = '.blog-article-content img',
18+
videoSelector = '[data-video-trigger], [data-video-recording-trigger]',
19+
class: className,
20+
} = Astro.props as MediaLightboxProps;
21+
---
22+
23+
<media-lightbox class={className} data-selector={selector} data-video-selector={videoSelector}>
24+
<dialog aria-label="Media viewer">
25+
<div class="modal-frame media-lightbox-frame">
26+
<button data-close-modal class="modal-close" aria-label="Close" type="button">
27+
<Icon name="close" size="sm" class="modal-close-icon" />
28+
</button>
29+
<div class="modal-content media-lightbox-content">
30+
<figure class="media-lightbox-figure" data-lightbox-image hidden>
31+
<img class="media-lightbox-img" src="" alt="" />
32+
<figcaption class="media-lightbox-caption" hidden></figcaption>
33+
</figure>
34+
<div class="modal-embed-wrapper media-lightbox-embed" data-lightbox-video hidden></div>
35+
</div>
36+
</div>
37+
</dialog>
38+
</media-lightbox>
39+
40+
<script>
41+
import { sanitizeYoutubeEmbedUrlForIframe } from '@utils/youtube';
42+
43+
class MediaLightbox extends HTMLElement {
44+
dialog: HTMLDialogElement;
45+
dialogFrame: HTMLElement;
46+
closeBtn: HTMLButtonElement;
47+
figure: HTMLElement;
48+
image: HTMLImageElement;
49+
caption: HTMLElement;
50+
embed: HTMLElement;
51+
dialogOpenedAt = 0;
52+
53+
constructor() {
54+
super();
55+
this.dialog = this.querySelector('dialog')!;
56+
this.dialogFrame = this.querySelector('.media-lightbox-frame')!;
57+
this.closeBtn = this.querySelector<HTMLButtonElement>('button[data-close-modal]')!;
58+
this.figure = this.querySelector<HTMLElement>('[data-lightbox-image]')!;
59+
this.image = this.querySelector<HTMLImageElement>('.media-lightbox-img')!;
60+
this.caption = this.querySelector<HTMLElement>('.media-lightbox-caption')!;
61+
this.embed = this.querySelector<HTMLElement>('[data-lightbox-video]')!;
62+
}
63+
64+
connectedCallback() {
65+
this.bindImageTriggers();
66+
this.bindVideoTriggers();
67+
this.setupEventListeners();
68+
}
69+
70+
bindImageTriggers() {
71+
const sel = this.dataset.selector;
72+
if (!sel) return;
73+
const triggers = document.querySelectorAll<HTMLImageElement>(sel);
74+
triggers.forEach((img) => {
75+
if (img.closest('media-lightbox')) return;
76+
if (img.dataset.lightboxBound === 'true') return;
77+
img.dataset.lightboxBound = 'true';
78+
img.classList.add('is-lightbox-trigger');
79+
img.setAttribute('role', 'button');
80+
img.setAttribute('tabindex', '0');
81+
if (!img.getAttribute('aria-label')) {
82+
img.setAttribute('aria-label', `Open image${img.alt ? `: ${img.alt}` : ''}`);
83+
}
84+
img.addEventListener('click', () => this.openImage(img.currentSrc || img.src, img.alt));
85+
img.addEventListener('keydown', (e) => {
86+
if (e.key === 'Enter' || e.key === ' ') {
87+
e.preventDefault();
88+
this.openImage(img.currentSrc || img.src, img.alt);
89+
}
90+
});
91+
});
92+
}
93+
94+
bindVideoTriggers() {
95+
const sel = this.dataset.videoSelector;
96+
if (!sel) return;
97+
const triggers = document.querySelectorAll<HTMLElement>(sel);
98+
triggers.forEach((el) => {
99+
if (el.closest('media-lightbox')) return;
100+
if (el.dataset.lightboxBound === 'true') return;
101+
const embed = el.getAttribute('data-embed-url');
102+
if (!embed) return;
103+
el.dataset.lightboxBound = 'true';
104+
const isNativelyFocusable =
105+
el instanceof HTMLAnchorElement || el instanceof HTMLButtonElement;
106+
if (!isNativelyFocusable && !el.hasAttribute('tabindex')) {
107+
el.setAttribute('tabindex', '0');
108+
}
109+
el.addEventListener('click', (e) => {
110+
e.preventDefault();
111+
this.openVideo(embed);
112+
});
113+
el.addEventListener('keydown', (e) => {
114+
if (e.key === 'Enter' || e.key === ' ') {
115+
e.preventDefault();
116+
this.openVideo(embed);
117+
}
118+
});
119+
});
120+
}
121+
122+
setupEventListeners() {
123+
this.closeBtn.addEventListener('click', () => this.closeModal());
124+
125+
this.dialog.addEventListener('close', () => {
126+
document.body.toggleAttribute('data-media-lightbox-open', false);
127+
window.removeEventListener('click', this.onClick);
128+
this.embed.innerHTML = '';
129+
this.embed.hidden = true;
130+
this.figure.hidden = true;
131+
this.image.src = '';
132+
});
133+
134+
window.addEventListener('keydown', (e) => {
135+
if (e.key === 'Escape' && this.dialog.open) {
136+
this.closeModal();
137+
e.preventDefault();
138+
}
139+
});
140+
}
141+
142+
onClick = (event: MouseEvent) => {
143+
const target = event.target as HTMLElement;
144+
const isInDialog = target.closest('dialog') !== null;
145+
const isInFrame = target.closest('.media-lightbox-frame') !== null;
146+
const isBackdropClick = isInDialog && !isInFrame;
147+
const isTooSoon = Date.now() - this.dialogOpenedAt < 300;
148+
if (isBackdropClick && !isTooSoon) this.closeModal();
149+
};
150+
151+
openImage(src: string, alt: string) {
152+
if (!src) return;
153+
this.embed.hidden = true;
154+
this.embed.innerHTML = '';
155+
this.figure.hidden = false;
156+
this.image.src = src;
157+
this.image.alt = alt || '';
158+
if (alt) {
159+
this.caption.textContent = alt;
160+
this.caption.hidden = false;
161+
} else {
162+
this.caption.textContent = '';
163+
this.caption.hidden = true;
164+
}
165+
this.show();
166+
}
167+
168+
openVideo(embedUrl: string) {
169+
const safe = sanitizeYoutubeEmbedUrlForIframe(embedUrl);
170+
if (!safe) return;
171+
this.figure.hidden = true;
172+
this.image.src = '';
173+
this.embed.hidden = false;
174+
this.embed.innerHTML = `
175+
<iframe
176+
width="560"
177+
height="315"
178+
src="${safe.replace(/"/g, '&quot;')}"
179+
title="YouTube video player"
180+
frameborder="0"
181+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
182+
referrerpolicy="strict-origin-when-cross-origin"
183+
allowfullscreen
184+
></iframe>
185+
`;
186+
this.show();
187+
}
188+
189+
show() {
190+
this.dialog.showModal();
191+
document.body.toggleAttribute('data-media-lightbox-open', true);
192+
this.dialogOpenedAt = Date.now();
193+
setTimeout(() => window.addEventListener('click', this.onClick), 100);
194+
}
195+
196+
closeModal() {
197+
this.dialog.close();
198+
}
199+
}
200+
201+
if (!customElements.get('media-lightbox')) {
202+
customElements.define('media-lightbox', MediaLightbox);
203+
}
204+
</script>

src/components/home/Video.astro

Lines changed: 3 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
---
22
import { Image } from 'astro:assets';
33
import Icon from '@components/Icon.astro';
4-
import VideoModal from './VideoModal.astro';
4+
import MediaLightbox from '@components/MediaLightbox.astro';
55
import ImageVideo from '@v1/assets/images/zac-video.png';
66
import { HOMEPAGE_FEATURE_VIDEO_EMBED_URL } from '@utils/youtube';
77
---
88

99
<div
1010
class="bg-midnight-fjord flex flex-row items-center gap-4 rounded-md p-2.5 pr-5 shadow-sm transition-opacity duration-300 hover:cursor-pointer hover:opacity-95 md:gap-5 hover:[&_button]:opacity-80"
1111
data-video-trigger
12+
data-embed-url={HOMEPAGE_FEATURE_VIDEO_EMBED_URL}
1213
>
1314
<div>
1415
<button
@@ -38,32 +39,4 @@ import { HOMEPAGE_FEATURE_VIDEO_EMBED_URL } from '@utils/youtube';
3839
<span class="datum-text-xs opacity-60">3 minute watch</span>
3940
</div>
4041
</div>
41-
<VideoModal />
42-
<script is:inline define:vars={{ homepageFeatureEmbedUrl: HOMEPAGE_FEATURE_VIDEO_EMBED_URL }}>
43-
document.addEventListener('DOMContentLoaded', () => {
44-
const trigger = document.querySelector('[data-video-trigger]');
45-
if (!trigger) return;
46-
47-
const openVideoModal = () => {
48-
if (window.VIDEO_MODAL_OPEN_EVENT) {
49-
document.dispatchEvent(
50-
new CustomEvent(window.VIDEO_MODAL_OPEN_EVENT, {
51-
detail: { embedUrl: homepageFeatureEmbedUrl },
52-
})
53-
);
54-
}
55-
};
56-
57-
trigger.addEventListener('click', (e) => {
58-
e.preventDefault();
59-
openVideoModal();
60-
});
61-
62-
trigger.addEventListener('keydown', (e) => {
63-
if (e.key === 'Enter' || e.key === ' ') {
64-
e.preventDefault();
65-
openVideoModal();
66-
}
67-
});
68-
});
69-
</script>
42+
<MediaLightbox selector="" />

0 commit comments

Comments
 (0)