This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
"description": "Armandine gallery – pre-rendered Svelte site",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build && node scripts/critical-css.js",
|
||||
"build": "vite build && node scripts/critical-css.js && node scripts/externalize-bootstrap.js",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
@@ -42,6 +42,7 @@
|
||||
"stylelint": "^17.3.0",
|
||||
"stylelint-config-standard": "^40.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"terser": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^7.3.1",
|
||||
|
||||
15
pnpm-lock.yaml
generated
15
pnpm-lock.yaml
generated
@@ -59,6 +59,9 @@ importers:
|
||||
svelte-check:
|
||||
specifier: ^4.0.0
|
||||
version: 4.4.0(picomatch@4.0.3)(svelte@5.51.2)(typescript@5.9.3)
|
||||
terser:
|
||||
specifier: ^5.0.0
|
||||
version: 5.46.0
|
||||
typescript:
|
||||
specifier: ^5.0.0
|
||||
version: 5.9.3
|
||||
@@ -2571,7 +2574,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
optional: true
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
|
||||
@@ -2921,8 +2923,7 @@ snapshots:
|
||||
node-releases: 2.0.27
|
||||
update-browserslist-db: 1.2.3(browserslist@4.28.1)
|
||||
|
||||
buffer-from@1.1.2:
|
||||
optional: true
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
cacheable@2.3.2:
|
||||
dependencies:
|
||||
@@ -2956,8 +2957,7 @@ snapshots:
|
||||
|
||||
colord@2.9.3: {}
|
||||
|
||||
commander@2.20.3:
|
||||
optional: true
|
||||
commander@2.20.3: {}
|
||||
|
||||
consola@2.15.3: {}
|
||||
|
||||
@@ -3820,10 +3820,8 @@ snapshots:
|
||||
dependencies:
|
||||
buffer-from: 1.1.2
|
||||
source-map: 0.6.1
|
||||
optional: true
|
||||
|
||||
source-map@0.6.1:
|
||||
optional: true
|
||||
source-map@0.6.1: {}
|
||||
|
||||
string-width@4.2.3:
|
||||
dependencies:
|
||||
@@ -3952,7 +3950,6 @@ snapshots:
|
||||
acorn: 8.15.0
|
||||
commander: 2.20.3
|
||||
source-map-support: 0.5.21
|
||||
optional: true
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
dependencies:
|
||||
|
||||
79
scripts/externalize-bootstrap.js
Normal file
79
scripts/externalize-bootstrap.js
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Move SvelteKit's inline bootstrap script to an external file for CSP (no unsafe-inline).
|
||||
* Run after vite build; reads/writes build/.
|
||||
* Finds <script>...</script> containing __sveltekit_, minifies it, writes to _app/immutable/entry/bootstrap.js,
|
||||
* and replaces the inline script with <script src="...">.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, readdirSync, mkdirSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { minify } from 'terser';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const buildDir = join(__dirname, '..', 'build');
|
||||
const entryDir = join(buildDir, '_app', 'immutable', 'entry');
|
||||
const bootstrapPath = join(entryDir, 'bootstrap.js');
|
||||
const scriptSrc = './_app/immutable/entry/bootstrap.js';
|
||||
|
||||
function getFiles(dir, ext, files = []) {
|
||||
for (const name of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, name.name);
|
||||
if (name.isDirectory()) getFiles(full, ext, files);
|
||||
else if (name.name.endsWith(ext)) files.push(full);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
// Find first <script>...</script> that contains __sveltekit_
|
||||
function findInlineBootstrap(html) {
|
||||
const scriptOpen = html.indexOf('<script>');
|
||||
if (scriptOpen === -1) return null;
|
||||
const scriptClose = html.indexOf('</script>', scriptOpen);
|
||||
if (scriptClose === -1) return null;
|
||||
const content = html.slice(scriptOpen + '<script>'.length, scriptClose);
|
||||
if (!content.includes('__sveltekit_')) return null;
|
||||
return { content: content.trim(), start: scriptOpen, end: scriptClose + '</script>'.length };
|
||||
}
|
||||
|
||||
const SCRIPT_TAG = `<script src="${scriptSrc}"></script>`;
|
||||
|
||||
async function main() {
|
||||
const htmlFiles = getFiles(buildDir, '.html');
|
||||
let bootstrapWritten = false;
|
||||
let count = 0;
|
||||
for (const htmlFile of htmlFiles) {
|
||||
let html = readFileSync(htmlFile, 'utf8');
|
||||
const found = findInlineBootstrap(html);
|
||||
if (!found) continue;
|
||||
|
||||
if (!bootstrapWritten) {
|
||||
// Imports relative to script location when in _app/immutable/entry/
|
||||
let scriptContent = found.content.replace(
|
||||
/import\("\.\/_app\/immutable\/entry\/([^"]+)"\)/g,
|
||||
'import("./$1")'
|
||||
);
|
||||
const result = await minify(scriptContent, {
|
||||
format: { comments: false },
|
||||
compress: { passes: 1 }
|
||||
});
|
||||
if (result.code) scriptContent = result.code;
|
||||
mkdirSync(entryDir, { recursive: true });
|
||||
writeFileSync(bootstrapPath, scriptContent, 'utf8');
|
||||
bootstrapWritten = true;
|
||||
}
|
||||
|
||||
html = html.slice(0, found.start) + SCRIPT_TAG + html.slice(found.end);
|
||||
writeFileSync(htmlFile, html, 'utf8');
|
||||
count++;
|
||||
}
|
||||
if (count > 0) {
|
||||
console.log('Bootstrap script externalized (minified):', scriptSrc, `(${count} HTML file(s))`);
|
||||
} else if (htmlFiles.length > 0) {
|
||||
console.log('No SvelteKit inline script found in HTML (bootstrap already external?)');
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
118
src/app.css
118
src/app.css
@@ -1,99 +1,67 @@
|
||||
:root {
|
||||
--bg: #fff;
|
||||
--fg: #222;
|
||||
--accent: #007acc;
|
||||
--bg: #fff;
|
||||
--fg: #222;
|
||||
--accent: #007acc;
|
||||
--lightbox-backdrop: rgb(255 255 255 / 90%);
|
||||
--lightbox-shadow: 0 0 10px 0 rgb(0 0 0 / 10%);
|
||||
--surface-elevated: #f3f3f3;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #111;
|
||||
--fg: #eee;
|
||||
--accent: #46c;
|
||||
}
|
||||
:root {
|
||||
--bg: #111;
|
||||
--fg: #eee;
|
||||
--accent: #46c;
|
||||
--lightbox-backdrop: rgb(0 0 0 / 90%);
|
||||
--surface-elevated: #222;
|
||||
}
|
||||
}
|
||||
|
||||
/* Explicit theme toggle overrides (win over media query when set) */
|
||||
html.dark {
|
||||
--bg: #111;
|
||||
--fg: #eee;
|
||||
--accent: #46c;
|
||||
}
|
||||
html {
|
||||
&[data-theme='dark'] {
|
||||
--bg: #111;
|
||||
--fg: #eee;
|
||||
--accent: #46c;
|
||||
--lightbox-backdrop: rgb(0 0 0 / 90%);
|
||||
--surface-elevated: #222;
|
||||
}
|
||||
|
||||
html.light {
|
||||
--bg: #fff;
|
||||
--fg: #222;
|
||||
--accent: #007acc;
|
||||
&[data-theme='light'] {
|
||||
--bg: #fff;
|
||||
--fg: #222;
|
||||
--accent: #007acc;
|
||||
--lightbox-backdrop: rgb(255 255 255 / 90%);
|
||||
--lightbox-shadow: 0 0 10px 0 rgb(0 0 0 / 10%);
|
||||
--surface-elevated: #f3f3f3;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: sans-serif;
|
||||
background-color: var(--bg);
|
||||
color: var(--fg);
|
||||
margin: 0;
|
||||
font-family: sans-serif;
|
||||
background-color: var(--bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.lightbox-open {
|
||||
overflow: hidden;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gallery-grid {
|
||||
column-count: 1;
|
||||
column-gap: 1rem;
|
||||
padding: 1rem;
|
||||
column-count: 1;
|
||||
column-gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@media (width >= 768px) {
|
||||
.gallery-grid {
|
||||
column-count: 2;
|
||||
}
|
||||
.gallery-grid {
|
||||
column-count: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 1024px) {
|
||||
.gallery-grid {
|
||||
column-count: 3;
|
||||
}
|
||||
}
|
||||
|
||||
/* Lightbox: not a Svelte component, styled globally for script.js target */
|
||||
#lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgb(0 0 0 / 90%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
#lightbox[aria-hidden='false'] {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#lb-content img,
|
||||
#lb-content video {
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
#lb-caption {
|
||||
color: #fff;
|
||||
margin-top: 0.5rem;
|
||||
text-align: center;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
#lb-close {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 2rem;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
.gallery-grid {
|
||||
column-count: 3;
|
||||
}
|
||||
}
|
||||
|
||||
14
src/app.d.ts
vendored
14
src/app.d.ts
vendored
@@ -1,13 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
@@ -1,83 +1,100 @@
|
||||
<script lang="ts">
|
||||
import type { MediaItem } from '$lib/media.js';
|
||||
import type { MediaItem } from '$lib/media.js';
|
||||
|
||||
interface Props {
|
||||
index?: number;
|
||||
item: MediaItem;
|
||||
}
|
||||
interface Props {
|
||||
index?: number;
|
||||
item: MediaItem;
|
||||
showLightbox: (item: MediaItem) => void;
|
||||
}
|
||||
|
||||
let { item, index }: Props = $props();
|
||||
let { item, index, showLightbox }: Props = $props();
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<figure
|
||||
class="gallery-item{item.type === 'video' ? ' video' : ''}"
|
||||
tabindex="0"
|
||||
data-name={item.name}
|
||||
data-type={item.type}
|
||||
data-caption={item.caption}
|
||||
<button
|
||||
class={`gallery-item ${item.type === 'video' ? ' video' : ''}`}
|
||||
tabindex="0"
|
||||
data-name={item.name}
|
||||
data-type={item.type}
|
||||
data-caption={item.caption}
|
||||
onclick={() => showLightbox(item)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
showLightbox(item);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<picture>
|
||||
{#each [
|
||||
{ bp: 'desktop', minWidth: 1024 },
|
||||
{ bp: 'tablet', minWidth: 768 },
|
||||
{ bp: 'mobile', minWidth: 0 }
|
||||
] as breakpoint}
|
||||
<source
|
||||
media="(min-width:{breakpoint.minWidth}px)"
|
||||
srcset={item.type === 'image'
|
||||
? `/assets/media/${breakpoint.bp}/${item.name}@1x.webp 1x, /assets/media/${breakpoint.bp}/${item.name}.webp 2x`
|
||||
: `/assets/media/${breakpoint.bp}/${item.name}_still@1x.webp 1x, /assets/media/${breakpoint.bp}/${item.name}_still.webp 2x`}
|
||||
/>
|
||||
{/each}
|
||||
<img
|
||||
src="/assets/media/thumbnail/{item.name}.webp"
|
||||
alt={item.alt.replace(/['']/g, '')}
|
||||
height={item.height ?? undefined}
|
||||
width={item.width ?? undefined}
|
||||
loading={index && index > 2 ? item.loading ?? 'lazy' : undefined}
|
||||
fetchpriority={item.fetchpriority ?? undefined}
|
||||
/>
|
||||
</picture>
|
||||
<figcaption>{item.caption}</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<picture>
|
||||
{#each [{ bp: 'desktop', minWidth: 1024 }, { bp: 'tablet', minWidth: 768 }, { bp: 'mobile', minWidth: 0 }] as breakpoint}
|
||||
<source
|
||||
media="(min-width:{breakpoint.minWidth}px)"
|
||||
srcset={item.type === 'image'
|
||||
? `/assets/media/${breakpoint.bp}/${item.name}@1x.webp 1x, /assets/media/${breakpoint.bp}/${item.name}.webp 2x`
|
||||
: `/assets/media/${breakpoint.bp}/${item.name}_still@1x.webp 1x, /assets/media/${breakpoint.bp}/${item.name}_still.webp 2x`}
|
||||
/>
|
||||
{/each}
|
||||
<img
|
||||
src="/assets/media/thumbnail/{item.name}.webp"
|
||||
alt={item.alt.replace(/['']/g, '')}
|
||||
height={item.height ?? undefined}
|
||||
width={item.width ?? undefined}
|
||||
loading={index && index > 2
|
||||
? (item.loading ?? 'lazy')
|
||||
: undefined}
|
||||
fetchpriority={item.fetchpriority ?? undefined}
|
||||
/>
|
||||
</picture>
|
||||
<figcaption>{item.caption}</figcaption>
|
||||
</figure>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.gallery-item {
|
||||
aspect-ratio: 3/2;
|
||||
margin: 0 0 1rem;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
.gallery-item {
|
||||
aspect-ratio: 3/2;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
margin: 0 0 1rem;
|
||||
position: relative;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
|
||||
& img {
|
||||
height: auto;
|
||||
width: 100%;
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
}
|
||||
& figure {
|
||||
margin: 0;
|
||||
}
|
||||
& img {
|
||||
height: auto;
|
||||
width: 100%;
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
& figcaption {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
background: rgb(0 0 0 / 60%);
|
||||
color: #fff;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
& figcaption {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
background: rgb(0 0 0 / 60%);
|
||||
color: #fff;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
& figcaption {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
&:focus,
|
||||
&:hover {
|
||||
& figcaption {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
140
src/lib/components/Lightbox.svelte
Normal file
140
src/lib/components/Lightbox.svelte
Normal file
@@ -0,0 +1,140 @@
|
||||
<script lang="ts">
|
||||
import type { MediaItem } from '$lib/media';
|
||||
|
||||
interface Props {
|
||||
item: MediaItem | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { item, onClose }: Props = $props();
|
||||
|
||||
let ref = $state<HTMLDialogElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (ref && item) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
ref.showModal();
|
||||
} else {
|
||||
document.body.style.overflow = 'auto';
|
||||
ref?.close();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<dialog
|
||||
class="lightbox"
|
||||
aria-hidden="true"
|
||||
onclose={onClose}
|
||||
closedby="any"
|
||||
aria-describedby="lb-caption"
|
||||
bind:this={ref}
|
||||
>
|
||||
{#if item}
|
||||
<header>
|
||||
<button
|
||||
class="lb-close"
|
||||
aria-label="Close"
|
||||
onclick={() => ref?.close()}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') ref?.close();
|
||||
}}>×</button
|
||||
>
|
||||
</header>
|
||||
<div class="lb-content">
|
||||
{#if item?.type === 'video'}
|
||||
<video
|
||||
src={`/assets/media/videos/${item?.name}.mp4`}
|
||||
controls
|
||||
autoplay
|
||||
>
|
||||
<track
|
||||
kind="captions"
|
||||
src={`/assets/media/videos/${item?.name}-captions.vtt`}
|
||||
default
|
||||
/>
|
||||
</video>
|
||||
{:else}
|
||||
<picture>
|
||||
{#each [{ bp: 'desktop', minWidth: 1024 }, { bp: 'tablet', minWidth: 768 }, { bp: 'mobile', minWidth: 0 }] as breakpoint}
|
||||
<source
|
||||
media="(min-width:{breakpoint.minWidth}px)"
|
||||
srcset={item?.type === 'image'
|
||||
? `/assets/media/${breakpoint.bp}/${item?.name}@1x.webp 1x, /assets/media/${breakpoint.bp}/${item?.name}.webp 2x`
|
||||
: `/assets/media/${breakpoint.bp}/${item?.name}_still@1x.webp 1x, /assets/media/${breakpoint.bp}/${item?.name}_still.webp 2x`}
|
||||
/>
|
||||
{/each}
|
||||
<img
|
||||
src="/assets/media/thumbnail/{item?.name}.webp"
|
||||
alt={item?.alt.replace(/['']/g, '')}
|
||||
/>
|
||||
</picture>
|
||||
{/if}
|
||||
</div>
|
||||
<p id="lb-caption" class="lb-caption">{item?.caption}</p>
|
||||
{/if}
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.lightbox {
|
||||
align-items: stretch;
|
||||
background: var(--surface-elevated);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--lightbox-shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
max-width: 90vw;
|
||||
padding: 0.5rem 1rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
|
||||
&::backdrop {
|
||||
background: var(--lightbox-backdrop);
|
||||
}
|
||||
|
||||
&[open] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
& img,
|
||||
& video {
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0.25rem 0.25rem 0 0;
|
||||
}
|
||||
|
||||
.lb-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 2rem;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.lb-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
& img {
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
}
|
||||
}
|
||||
|
||||
.lb-caption {
|
||||
color: var(--fg);
|
||||
margin-top: 0.5rem;
|
||||
text-align: center;
|
||||
max-width: 90vw;
|
||||
}
|
||||
</style>
|
||||
@@ -1,30 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { theme } from '$lib/stores/theme.svelte.js';
|
||||
|
||||
const toggleTheme = () => {
|
||||
theme.set(theme.get() === 'light' ? 'dark' : 'light');
|
||||
};
|
||||
</script>
|
||||
|
||||
<header class="site-header">
|
||||
<h1>64 Armandine St #3 Boston, Massachusetts</h1>
|
||||
<div class="buttons">
|
||||
<button id="show_video" class="emoji-button" aria-label="Show video tour">
|
||||
🎥
|
||||
</button>
|
||||
<button id="theme-toggle" class="emoji-button" aria-label="Toggle light/dark theme">
|
||||
🌓
|
||||
</button>
|
||||
</div>
|
||||
<h1>64 Armandine St #3 Boston, Massachusetts</h1>
|
||||
<div class="buttons">
|
||||
<button
|
||||
id="show_video"
|
||||
class="emoji-button"
|
||||
aria-label="Show video tour"
|
||||
>
|
||||
🎥
|
||||
</button>
|
||||
<button
|
||||
id="theme-toggle"
|
||||
class="emoji-button"
|
||||
aria-label="Toggle light/dark theme"
|
||||
onclick={toggleTheme}
|
||||
>
|
||||
🌓
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.site-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
.site-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.emoji-button {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--accent);
|
||||
}
|
||||
.emoji-button {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
|
||||
350
src/lib/media.ts
350
src/lib/media.ts
@@ -1,178 +1,182 @@
|
||||
export interface MediaItem {
|
||||
type: 'image' | 'video';
|
||||
name: string;
|
||||
caption: string;
|
||||
alt: string;
|
||||
height?: number;
|
||||
width?: number;
|
||||
loading?: 'lazy' | 'eager';
|
||||
fetchpriority?: 'high' | 'low' | 'auto';
|
||||
type: 'image' | 'video';
|
||||
name: string;
|
||||
caption: string;
|
||||
alt: string;
|
||||
height?: number;
|
||||
width?: number;
|
||||
loading?: 'lazy' | 'eager';
|
||||
fetchpriority?: 'high' | 'low' | 'auto';
|
||||
}
|
||||
|
||||
export const mediaItems: MediaItem[] = [
|
||||
{
|
||||
type: 'image',
|
||||
name: 'living_room_1',
|
||||
caption: 'An inviting blend of comfort and curated art—relaxation guaranteed.',
|
||||
alt: 'Sunny living room with stylish seating and vibrant artwork.',
|
||||
height: 200,
|
||||
width: 300,
|
||||
loading: 'eager',
|
||||
fetchpriority: 'high'
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'living_room_2',
|
||||
caption: 'Relaxation elevated—your stylish living space awaits.',
|
||||
alt: 'Spacious living area featuring elegant furniture and tasteful decor.',
|
||||
height: 200,
|
||||
width: 300,
|
||||
fetchpriority: 'high'
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'kitchen',
|
||||
caption: 'The culinary stage is set—snacking encouraged, style required.',
|
||||
alt: 'Modern kitchen showcasing sleek appliances and contemporary design.',
|
||||
height: 200,
|
||||
width: 300
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'bedroom_suite_1',
|
||||
caption: 'A bedroom suite designed to make snoozing irresistible.',
|
||||
alt: 'Inviting bedroom suite with cozy bedding and warm lighting.',
|
||||
height: 200,
|
||||
width: 300
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'bedroom_suite_2',
|
||||
caption: 'Style meets comfort—sleeping in has never been easier.',
|
||||
alt: 'Comfortable bedroom suite with elegant decor and soft tones.',
|
||||
height: 200,
|
||||
width: 300
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'bedroom_suite_3',
|
||||
caption: 'Where dreams get stylish—a bedroom that feels like home.',
|
||||
alt: 'Welcoming bedroom with soothing colors and inviting ambiance.',
|
||||
height: 200,
|
||||
width: 300
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'guest_bath',
|
||||
caption: 'Your personal spa experience—right down the hall.',
|
||||
alt: 'Sophisticated guest bathroom with modern fixtures and clean lines.',
|
||||
height: 450,
|
||||
width: 300
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'onsuite_1',
|
||||
caption: 'Luxury meets practicality—your private ensuite awaits.',
|
||||
alt: 'Private ensuite bathroom featuring contemporary design and premium finishes.',
|
||||
height: 450,
|
||||
width: 300,
|
||||
loading: 'eager',
|
||||
fetchpriority: 'high'
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'onsuite_2',
|
||||
caption: 'Everyday luxury, right at home—your ensuite oasis.',
|
||||
alt: 'Elegant ensuite with sleek fixtures and stylish decor.',
|
||||
height: 200,
|
||||
width: 300
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'laundry',
|
||||
caption: 'Laundry day reimagined—functional never looked so good.',
|
||||
alt: 'Modern laundry room with washer, dryer, and organized storage.',
|
||||
height: 450,
|
||||
width: 300
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'coat_closet',
|
||||
caption: 'Organized and chic—your entryway\'s best friend.',
|
||||
alt: 'Convenient coat closet with tidy storage solutions.',
|
||||
height: 200,
|
||||
width: 300
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'deck_1',
|
||||
caption: 'Outdoor comfort, just steps away—morning coffee optional.',
|
||||
alt: 'Sunny deck with cozy seating and pleasant outdoor views.',
|
||||
height: 450,
|
||||
width: 300
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'deck_2',
|
||||
caption: 'Your fresh-air escape—ideal for relaxing evenings.',
|
||||
alt: 'Comfortable deck area perfect for unwinding or entertaining.',
|
||||
height: 200,
|
||||
width: 300
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'exterior',
|
||||
caption: 'Curb appeal perfected—your new favorite place starts here.',
|
||||
alt: 'Attractive home exterior with inviting architecture.',
|
||||
height: 200,
|
||||
width: 300
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'backyard_parking',
|
||||
caption: 'Convenience meets privacy—your personal backyard parking spot.',
|
||||
alt: 'Private backyard parking area offering secure convenience.',
|
||||
height: 200,
|
||||
width: 300
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'office_fitness_guest_1',
|
||||
caption: 'Productivity zone meets fitness corner—multitasking done right.',
|
||||
alt: 'Dual-purpose room featuring office setup and fitness equipment.',
|
||||
height: 200,
|
||||
width: 300
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'office_fitness_guest_2',
|
||||
caption: 'Work, workout, or unwind—the room of endless possibilities.',
|
||||
alt: 'Versatile office and fitness area with modern amenities.',
|
||||
height: 200,
|
||||
width: 300
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'office_fitness_guest_3',
|
||||
caption: 'Stay focused or get fit—you decide.',
|
||||
alt: 'Functional space combining a workspace and home fitness area.',
|
||||
height: 200,
|
||||
width: 300
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'office_fitness_guest_4',
|
||||
caption: 'Room for every routine—your workspace meets wellness.',
|
||||
alt: 'Stylish office area seamlessly integrated with fitness features.',
|
||||
height: 200,
|
||||
width: 300
|
||||
},
|
||||
{
|
||||
type: 'video',
|
||||
name: 'tour',
|
||||
caption: "Take the scenic route—explore your the home's highlights with a virtual walkthrough.",
|
||||
alt: 'Video tour showcasing the property.',
|
||||
height: 534,
|
||||
width: 300
|
||||
}
|
||||
{
|
||||
type: 'image',
|
||||
name: 'living_room_1',
|
||||
caption:
|
||||
'An inviting blend of comfort and curated art—relaxation guaranteed.',
|
||||
alt: 'Sunny living room with stylish seating and vibrant artwork.',
|
||||
height: 200,
|
||||
width: 300,
|
||||
fetchpriority: 'high',
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'living_room_2',
|
||||
caption: 'Relaxation elevated—your stylish living space awaits.',
|
||||
alt: 'Spacious living area featuring elegant furniture and tasteful decor.',
|
||||
height: 200,
|
||||
width: 300,
|
||||
fetchpriority: 'high',
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'kitchen',
|
||||
caption:
|
||||
'The culinary stage is set—snacking encouraged, style required.',
|
||||
alt: 'Modern kitchen showcasing sleek appliances and contemporary design.',
|
||||
height: 200,
|
||||
width: 300,
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'bedroom_suite_1',
|
||||
caption: 'A bedroom suite designed to make snoozing irresistible.',
|
||||
alt: 'Inviting bedroom suite with cozy bedding and warm lighting.',
|
||||
height: 200,
|
||||
width: 300,
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'bedroom_suite_2',
|
||||
caption: 'Style meets comfort—sleeping in has never been easier.',
|
||||
alt: 'Comfortable bedroom suite with elegant decor and soft tones.',
|
||||
height: 200,
|
||||
width: 300,
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'bedroom_suite_3',
|
||||
caption: 'Where dreams get stylish—a bedroom that feels like home.',
|
||||
alt: 'Welcoming bedroom with soothing colors and inviting ambiance.',
|
||||
height: 200,
|
||||
width: 300,
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'guest_bath',
|
||||
caption: 'Your personal spa experience—right down the hall.',
|
||||
alt: 'Sophisticated guest bathroom with modern fixtures and clean lines.',
|
||||
height: 450,
|
||||
width: 300,
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'onsuite_1',
|
||||
caption: 'Luxury meets practicality—your private ensuite awaits.',
|
||||
alt: 'Private ensuite bathroom featuring contemporary design and premium finishes.',
|
||||
height: 450,
|
||||
width: 300,
|
||||
loading: 'eager',
|
||||
fetchpriority: 'high',
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'onsuite_2',
|
||||
caption: 'Everyday luxury, right at home—your ensuite oasis.',
|
||||
alt: 'Elegant ensuite with sleek fixtures and stylish decor.',
|
||||
height: 200,
|
||||
width: 300,
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'laundry',
|
||||
caption: 'Laundry day reimagined—functional never looked so good.',
|
||||
alt: 'Modern laundry room with washer, dryer, and organized storage.',
|
||||
height: 450,
|
||||
width: 300,
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'coat_closet',
|
||||
caption: "Organized and chic—your entryway's best friend.",
|
||||
alt: 'Convenient coat closet with tidy storage solutions.',
|
||||
height: 200,
|
||||
width: 300,
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'deck_1',
|
||||
caption: 'Outdoor comfort, just steps away—morning coffee optional.',
|
||||
alt: 'Sunny deck with cozy seating and pleasant outdoor views.',
|
||||
height: 450,
|
||||
width: 300,
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'deck_2',
|
||||
caption: 'Your fresh-air escape—ideal for relaxing evenings.',
|
||||
alt: 'Comfortable deck area perfect for unwinding or entertaining.',
|
||||
height: 200,
|
||||
width: 300,
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'exterior',
|
||||
caption: 'Curb appeal perfected—your new favorite place starts here.',
|
||||
alt: 'Attractive home exterior with inviting architecture.',
|
||||
height: 200,
|
||||
width: 300,
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'backyard_parking',
|
||||
caption:
|
||||
'Convenience meets privacy—your personal backyard parking spot.',
|
||||
alt: 'Private backyard parking area offering secure convenience.',
|
||||
height: 200,
|
||||
width: 300,
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'office_fitness_guest_1',
|
||||
caption:
|
||||
'Productivity zone meets fitness corner—multitasking done right.',
|
||||
alt: 'Dual-purpose room featuring office setup and fitness equipment.',
|
||||
height: 200,
|
||||
width: 300,
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'office_fitness_guest_2',
|
||||
caption: 'Work, workout, or unwind—the room of endless possibilities.',
|
||||
alt: 'Versatile office and fitness area with modern amenities.',
|
||||
height: 200,
|
||||
width: 300,
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'office_fitness_guest_3',
|
||||
caption: 'Stay focused or get fit—you decide.',
|
||||
alt: 'Functional space combining a workspace and home fitness area.',
|
||||
height: 200,
|
||||
width: 300,
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
name: 'office_fitness_guest_4',
|
||||
caption: 'Room for every routine—your workspace meets wellness.',
|
||||
alt: 'Stylish office area seamlessly integrated with fitness features.',
|
||||
height: 200,
|
||||
width: 300,
|
||||
},
|
||||
{
|
||||
type: 'video',
|
||||
name: 'tour',
|
||||
caption:
|
||||
"Take the scenic route—explore your the home's highlights with a virtual walkthrough.",
|
||||
alt: 'Video tour showcasing the property.',
|
||||
height: 534,
|
||||
width: 300,
|
||||
},
|
||||
];
|
||||
|
||||
10
src/lib/stores/theme.svelte.ts
Normal file
10
src/lib/stores/theme.svelte.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
let mode = $state<'light' | 'dark'>('light');
|
||||
|
||||
export const theme = {
|
||||
get: () => mode,
|
||||
set: (value: 'light' | 'dark') => {
|
||||
mode = value;
|
||||
document.documentElement.setAttribute('data-theme', value);
|
||||
localStorage.setItem('theme', value);
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import '../app.css';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<slot />
|
||||
{@render children()}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export const prerender = true;
|
||||
export const ssr = true;
|
||||
|
||||
@@ -1,68 +1,88 @@
|
||||
<script lang="ts">
|
||||
import SiteHeader from '$lib/components/SiteHeader.svelte';
|
||||
import GalleryFigure from '$lib/components/GalleryFigure.svelte';
|
||||
import type { MediaItem } from '$lib/media.js';
|
||||
import Lightbox from '$lib/components/Lightbox.svelte';
|
||||
import GalleryFigure from '$lib/components/GalleryFigure.svelte';
|
||||
import SiteHeader from '$lib/components/SiteHeader.svelte';
|
||||
|
||||
interface Props {
|
||||
data: { mediaItems: import('$lib/media.js').MediaItem[] };
|
||||
}
|
||||
let { data }: Props = $props();
|
||||
interface Props {
|
||||
data: { mediaItems: MediaItem[] };
|
||||
}
|
||||
|
||||
const title = '64 Armandine St #3 Boston, Massachusetts';
|
||||
const description =
|
||||
'An inviting blend of comfort and curated art—relaxation guaranteed.';
|
||||
const canonical = 'https://armandine.example.com'; // replace with real URL
|
||||
const gaId = 'G-QZGFK4MDT4';
|
||||
let { data }: Props = $props();
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Place',
|
||||
name: title,
|
||||
description,
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
streetAddress: '64 Armandine St #3',
|
||||
addressLocality: 'Boston',
|
||||
addressRegion: 'MA',
|
||||
addressCountry: 'US'
|
||||
}
|
||||
};
|
||||
const title = '64 Armandine St #3 Boston, Massachusetts';
|
||||
const description =
|
||||
'An inviting blend of comfort and curated art—relaxation guaranteed.';
|
||||
const canonical = 'https://armandine.mifi.holdings/';
|
||||
const gaId = 'G-QZGFK4MDT4';
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Place',
|
||||
name: title,
|
||||
description,
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
streetAddress: '64 Armandine St #3',
|
||||
addressLocality: 'Boston',
|
||||
addressRegion: 'MA',
|
||||
addressCountry: 'US',
|
||||
},
|
||||
};
|
||||
|
||||
let showPicture = $state<MediaItem | null>(null);
|
||||
|
||||
const showLightbox = (item: MediaItem) => {
|
||||
showPicture = item;
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
showPicture = null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<link rel="canonical" href={canonical} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<link rel="canonical" href={canonical} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={canonical} />
|
||||
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16x16.png" />
|
||||
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="/assets/favicon-32x32.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="/assets/favicon-16x16.png"
|
||||
/>
|
||||
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
|
||||
|
||||
<link rel="preload" href="/assets/js/script.js" as="script" />
|
||||
<script defer src="/assets/js/script.js"></script>
|
||||
<link rel="preload" href="/assets/js/script.js" as="script" />
|
||||
<script defer src="/assets/js/script.js"></script>
|
||||
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id={gaId}"></script>
|
||||
<script defer src="/assets/js/ga-init.js" data-ga-id={gaId}></script>
|
||||
<script
|
||||
async
|
||||
src="https://www.googletagmanager.com/gtag/js?id={gaId}"
|
||||
></script>
|
||||
<script defer src="/assets/js/ga-init.js" data-ga-id={gaId}></script>
|
||||
|
||||
{@html `<script type="application/ld+json">${JSON.stringify(jsonLd)}</script>`}
|
||||
{@html `<script type="application/ld+json">${JSON.stringify(jsonLd)}</script>`}
|
||||
</svelte:head>
|
||||
|
||||
<SiteHeader />
|
||||
<main>
|
||||
<section id="gallery" class="gallery-grid">
|
||||
{#each data.mediaItems as item}
|
||||
<GalleryFigure {item} />
|
||||
{/each}
|
||||
</section>
|
||||
<section id="gallery" class="gallery-grid">
|
||||
{#each data.mediaItems as item, index (item.name)}
|
||||
<GalleryFigure {item} {index} {showLightbox} />
|
||||
{/each}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="lightbox" aria-hidden="true">
|
||||
<button id="lb-close" aria-label="Close">×</button>
|
||||
<div id="lb-content"></div>
|
||||
<p id="lb-caption"></p>
|
||||
</div>
|
||||
|
||||
<Lightbox item={showPicture} {onClose} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mediaItems } from '$lib/media.js';
|
||||
|
||||
export function load() {
|
||||
return { mediaItems };
|
||||
return { mediaItems };
|
||||
}
|
||||
|
||||
1
static/.well-known/appspecific/com.chrome.devtools.json
Normal file
1
static/.well-known/appspecific/com.chrome.devtools.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -1,108 +1,13 @@
|
||||
// --- theme toggle ---
|
||||
const toggle = document.getElementById('theme-toggle');
|
||||
const root = document.documentElement;
|
||||
const saved = window?.localStorage?.getItem('dark-mode');
|
||||
const saved = window?.localStorage?.getItem('theme');
|
||||
const sysDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (saved === 'true') {
|
||||
root.classList.add('dark');
|
||||
root.classList.remove('light');
|
||||
} else if (saved === 'false') {
|
||||
root.classList.add('light');
|
||||
root.classList.remove('dark');
|
||||
|
||||
if (saved) {
|
||||
root.setAttribute('data-theme', saved);
|
||||
} else {
|
||||
if (sysDark) {
|
||||
root.classList.add('dark');
|
||||
root.classList.remove('light');
|
||||
root.setAttribute('data-theme', 'dark');
|
||||
} else {
|
||||
root.classList.add('light');
|
||||
root.classList.remove('dark');
|
||||
root.setAttribute('data-theme', 'light');
|
||||
}
|
||||
}
|
||||
toggle?.addEventListener('click', () => {
|
||||
const isDark = root.classList.contains('dark');
|
||||
root.classList.toggle('dark', !isDark);
|
||||
root.classList.toggle('light', isDark);
|
||||
window?.localStorage?.setItem('dark-mode', String(!isDark));
|
||||
});
|
||||
|
||||
const { body } = document;
|
||||
|
||||
// --- lightbox base ---
|
||||
const lb = document.getElementById('lightbox');
|
||||
const lbCnt = document.getElementById('lb-content');
|
||||
const lbCap = document.getElementById('lb-caption');
|
||||
document.getElementById('lb-close')?.addEventListener('click', () => {
|
||||
lb?.setAttribute('aria-hidden', 'true');
|
||||
body.classList.remove('lightbox-open');
|
||||
if (lbCnt) lbCnt.innerHTML = '';
|
||||
});
|
||||
|
||||
// Build picture element for lightbox (name, type only)
|
||||
function createPicture(name, type) {
|
||||
const pic = document.createElement('picture');
|
||||
const breakpoints = [
|
||||
{ bp: 'desktop', minWidth: 1024 },
|
||||
{ bp: 'tablet', minWidth: 768 },
|
||||
{ bp: 'mobile', minWidth: 0 }
|
||||
];
|
||||
for (const { bp, minWidth } of breakpoints) {
|
||||
const src = document.createElement('source');
|
||||
src.media = `(min-width:${minWidth}px)`;
|
||||
if (type === 'image') {
|
||||
src.srcset =
|
||||
`/assets/media/${bp}/${name}@1x.webp 1x, /assets/media/${bp}/${name}.webp 2x`;
|
||||
} else {
|
||||
src.srcset =
|
||||
`/assets/media/${bp}/${name}_still@1x.webp 1x, /assets/media/${bp}/${name}_still.webp 2x`;
|
||||
}
|
||||
pic.appendChild(src);
|
||||
}
|
||||
const img = document.createElement('img');
|
||||
img.src = `/assets/media/thumbnail/${name}.webp`;
|
||||
img.alt = '';
|
||||
pic.appendChild(img);
|
||||
return pic;
|
||||
}
|
||||
|
||||
function openLightbox(item) {
|
||||
if (!lbCnt || !lbCap || !lb) return;
|
||||
lbCnt.innerHTML = '';
|
||||
if (item.type === 'video') {
|
||||
const v = document.createElement('video');
|
||||
v.src = `/assets/media/videos/${item.name}.mp4`;
|
||||
v.controls = true;
|
||||
v.autoplay = true;
|
||||
lbCnt.appendChild(v);
|
||||
} else {
|
||||
lbCnt.appendChild(createPicture(item.name, item.type));
|
||||
}
|
||||
lbCap.textContent = item.caption;
|
||||
body.classList.add('lightbox-open');
|
||||
lb.setAttribute('aria-hidden', 'false');
|
||||
}
|
||||
|
||||
// --- bind to pre-rendered gallery ---
|
||||
document.querySelectorAll('.gallery-item').forEach((fig) => {
|
||||
const name = fig.getAttribute('data-name');
|
||||
const type = fig.getAttribute('data-type');
|
||||
const caption = fig.getAttribute('data-caption');
|
||||
if (!name || !type || !caption) return;
|
||||
const item = { name, type, caption };
|
||||
fig.addEventListener('click', () => openLightbox(item));
|
||||
fig.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') openLightbox(item);
|
||||
});
|
||||
});
|
||||
|
||||
// --- video toggle ---
|
||||
const videoTgl = document.getElementById('show_video');
|
||||
videoTgl?.addEventListener('click', () => {
|
||||
const videoFig = document.querySelector('.gallery-item.video');
|
||||
if (videoFig) {
|
||||
openLightbox({
|
||||
name: videoFig.getAttribute('data-name'),
|
||||
type: videoFig.getAttribute('data-type'),
|
||||
caption: videoFig.getAttribute('data-caption')
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
4
static/assets/media/videos/tour-captions.vtt
Normal file
4
static/assets/media/videos/tour-captions.vtt
Normal file
@@ -0,0 +1,4 @@
|
||||
WEBVTT
|
||||
|
||||
00:00:00.000 --> 00:00:02.000
|
||||
No audio. Silent video tour.
|
||||
Reference in New Issue
Block a user