Finished the Sveltification
Some checks failed
ci/woodpecker/pr/ci Pipeline failed

This commit is contained in:
2026-02-15 23:01:16 -03:00
parent 99cb89d1e8
commit 4f863e5686
17 changed files with 683 additions and 517 deletions

View File

@@ -7,7 +7,7 @@
"description": "Armandine gallery pre-rendered Svelte site", "description": "Armandine gallery pre-rendered Svelte site",
"scripts": { "scripts": {
"dev": "vite dev", "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", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
@@ -42,6 +42,7 @@
"stylelint": "^17.3.0", "stylelint": "^17.3.0",
"stylelint-config-standard": "^40.0.0", "stylelint-config-standard": "^40.0.0",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"terser": "^5.0.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"typescript": "^5.0.0", "typescript": "^5.0.0",
"vite": "^7.3.1", "vite": "^7.3.1",

15
pnpm-lock.yaml generated
View File

@@ -59,6 +59,9 @@ importers:
svelte-check: svelte-check:
specifier: ^4.0.0 specifier: ^4.0.0
version: 4.4.0(picomatch@4.0.3)(svelte@5.51.2)(typescript@5.9.3) 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: typescript:
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.9.3 version: 5.9.3
@@ -2571,7 +2574,6 @@ snapshots:
dependencies: dependencies:
'@jridgewell/gen-mapping': 0.3.13 '@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31 '@jridgewell/trace-mapping': 0.3.31
optional: true
'@jridgewell/sourcemap-codec@1.5.5': {} '@jridgewell/sourcemap-codec@1.5.5': {}
@@ -2921,8 +2923,7 @@ snapshots:
node-releases: 2.0.27 node-releases: 2.0.27
update-browserslist-db: 1.2.3(browserslist@4.28.1) update-browserslist-db: 1.2.3(browserslist@4.28.1)
buffer-from@1.1.2: buffer-from@1.1.2: {}
optional: true
cacheable@2.3.2: cacheable@2.3.2:
dependencies: dependencies:
@@ -2956,8 +2957,7 @@ snapshots:
colord@2.9.3: {} colord@2.9.3: {}
commander@2.20.3: commander@2.20.3: {}
optional: true
consola@2.15.3: {} consola@2.15.3: {}
@@ -3820,10 +3820,8 @@ snapshots:
dependencies: dependencies:
buffer-from: 1.1.2 buffer-from: 1.1.2
source-map: 0.6.1 source-map: 0.6.1
optional: true
source-map@0.6.1: source-map@0.6.1: {}
optional: true
string-width@4.2.3: string-width@4.2.3:
dependencies: dependencies:
@@ -3952,7 +3950,6 @@ snapshots:
acorn: 8.15.0 acorn: 8.15.0
commander: 2.20.3 commander: 2.20.3
source-map-support: 0.5.21 source-map-support: 0.5.21
optional: true
tinyglobby@0.2.15: tinyglobby@0.2.15:
dependencies: dependencies:

View 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);
});

View File

@@ -2,6 +2,9 @@
--bg: #fff; --bg: #fff;
--fg: #222; --fg: #222;
--accent: #007acc; --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) { @media (prefers-color-scheme: dark) {
@@ -9,20 +12,29 @@
--bg: #111; --bg: #111;
--fg: #eee; --fg: #eee;
--accent: #46c; --accent: #46c;
--lightbox-backdrop: rgb(0 0 0 / 90%);
--surface-elevated: #222;
} }
} }
/* Explicit theme toggle overrides (win over media query when set) */ /* Explicit theme toggle overrides (win over media query when set) */
html.dark { html {
&[data-theme='dark'] {
--bg: #111; --bg: #111;
--fg: #eee; --fg: #eee;
--accent: #46c; --accent: #46c;
} --lightbox-backdrop: rgb(0 0 0 / 90%);
--surface-elevated: #222;
}
html.light { &[data-theme='light'] {
--bg: #fff; --bg: #fff;
--fg: #222; --fg: #222;
--accent: #007acc; --accent: #007acc;
--lightbox-backdrop: rgb(255 255 255 / 90%);
--lightbox-shadow: 0 0 10px 0 rgb(0 0 0 / 10%);
--surface-elevated: #f3f3f3;
}
} }
body { body {
@@ -53,47 +65,3 @@ body {
column-count: 3; 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;
}

View File

@@ -4,25 +4,29 @@
interface Props { interface Props {
index?: number; index?: number;
item: MediaItem; item: MediaItem;
showLightbox: (item: MediaItem) => void;
} }
let { item, index }: Props = $props(); let { item, index, showLightbox }: Props = $props();
</script> </script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex --> <button
<figure class={`gallery-item ${item.type === 'video' ? ' video' : ''}`}
class="gallery-item{item.type === 'video' ? ' video' : ''}"
tabindex="0" tabindex="0"
data-name={item.name} data-name={item.name}
data-type={item.type} data-type={item.type}
data-caption={item.caption} data-caption={item.caption}
onclick={() => showLightbox(item)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
showLightbox(item);
}
}}
> >
<figure>
<picture> <picture>
{#each [ {#each [{ bp: 'desktop', minWidth: 1024 }, { bp: 'tablet', minWidth: 768 }, { bp: 'mobile', minWidth: 0 }] as breakpoint}
{ bp: 'desktop', minWidth: 1024 },
{ bp: 'tablet', minWidth: 768 },
{ bp: 'mobile', minWidth: 0 }
] as breakpoint}
<source <source
media="(min-width:{breakpoint.minWidth}px)" media="(min-width:{breakpoint.minWidth}px)"
srcset={item.type === 'image' srcset={item.type === 'image'
@@ -35,20 +39,33 @@
alt={item.alt.replace(/['']/g, '')} alt={item.alt.replace(/['']/g, '')}
height={item.height ?? undefined} height={item.height ?? undefined}
width={item.width ?? undefined} width={item.width ?? undefined}
loading={index && index > 2 ? item.loading ?? 'lazy' : undefined} loading={index && index > 2
? (item.loading ?? 'lazy')
: undefined}
fetchpriority={item.fetchpriority ?? undefined} fetchpriority={item.fetchpriority ?? undefined}
/> />
</picture> </picture>
<figcaption>{item.caption}</figcaption> <figcaption>{item.caption}</figcaption>
</figure> </figure>
</button>
<style> <style>
.gallery-item { .gallery-item {
aspect-ratio: 3/2; aspect-ratio: 3/2;
background: none;
border: none;
color: inherit;
cursor: pointer;
font: inherit;
margin: 0 0 1rem; margin: 0 0 1rem;
position: relative; position: relative;
cursor: pointer; padding: 0;
text-align: left;
width: 100%;
& figure {
margin: 0;
}
& img { & img {
height: auto; height: auto;
width: 100%; width: 100%;

View 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();
}}>&times;</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>

View File

@@ -1,10 +1,27 @@
<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"> <header class="site-header">
<h1>64 Armandine St #3 Boston, Massachusetts</h1> <h1>64 Armandine St #3 Boston, Massachusetts</h1>
<div class="buttons"> <div class="buttons">
<button id="show_video" class="emoji-button" aria-label="Show video tour"> <button
id="show_video"
class="emoji-button"
aria-label="Show video tour"
>
🎥 🎥
</button> </button>
<button id="theme-toggle" class="emoji-button" aria-label="Toggle light/dark theme"> <button
id="theme-toggle"
class="emoji-button"
aria-label="Toggle light/dark theme"
onclick={toggleTheme}
>
🌓 🌓
</button> </button>
</div> </div>

View File

@@ -13,12 +13,12 @@ export const mediaItems: MediaItem[] = [
{ {
type: 'image', type: 'image',
name: 'living_room_1', name: 'living_room_1',
caption: 'An inviting blend of comfort and curated art—relaxation guaranteed.', caption:
'An inviting blend of comfort and curated art—relaxation guaranteed.',
alt: 'Sunny living room with stylish seating and vibrant artwork.', alt: 'Sunny living room with stylish seating and vibrant artwork.',
height: 200, height: 200,
width: 300, width: 300,
loading: 'eager', fetchpriority: 'high',
fetchpriority: 'high'
}, },
{ {
type: 'image', type: 'image',
@@ -27,15 +27,16 @@ export const mediaItems: MediaItem[] = [
alt: 'Spacious living area featuring elegant furniture and tasteful decor.', alt: 'Spacious living area featuring elegant furniture and tasteful decor.',
height: 200, height: 200,
width: 300, width: 300,
fetchpriority: 'high' fetchpriority: 'high',
}, },
{ {
type: 'image', type: 'image',
name: 'kitchen', name: 'kitchen',
caption: 'The culinary stage is set—snacking encouraged, style required.', caption:
'The culinary stage is set—snacking encouraged, style required.',
alt: 'Modern kitchen showcasing sleek appliances and contemporary design.', alt: 'Modern kitchen showcasing sleek appliances and contemporary design.',
height: 200, height: 200,
width: 300 width: 300,
}, },
{ {
type: 'image', type: 'image',
@@ -43,7 +44,7 @@ export const mediaItems: MediaItem[] = [
caption: 'A bedroom suite designed to make snoozing irresistible.', caption: 'A bedroom suite designed to make snoozing irresistible.',
alt: 'Inviting bedroom suite with cozy bedding and warm lighting.', alt: 'Inviting bedroom suite with cozy bedding and warm lighting.',
height: 200, height: 200,
width: 300 width: 300,
}, },
{ {
type: 'image', type: 'image',
@@ -51,7 +52,7 @@ export const mediaItems: MediaItem[] = [
caption: 'Style meets comfort—sleeping in has never been easier.', caption: 'Style meets comfort—sleeping in has never been easier.',
alt: 'Comfortable bedroom suite with elegant decor and soft tones.', alt: 'Comfortable bedroom suite with elegant decor and soft tones.',
height: 200, height: 200,
width: 300 width: 300,
}, },
{ {
type: 'image', type: 'image',
@@ -59,7 +60,7 @@ export const mediaItems: MediaItem[] = [
caption: 'Where dreams get stylish—a bedroom that feels like home.', caption: 'Where dreams get stylish—a bedroom that feels like home.',
alt: 'Welcoming bedroom with soothing colors and inviting ambiance.', alt: 'Welcoming bedroom with soothing colors and inviting ambiance.',
height: 200, height: 200,
width: 300 width: 300,
}, },
{ {
type: 'image', type: 'image',
@@ -67,7 +68,7 @@ export const mediaItems: MediaItem[] = [
caption: 'Your personal spa experience—right down the hall.', caption: 'Your personal spa experience—right down the hall.',
alt: 'Sophisticated guest bathroom with modern fixtures and clean lines.', alt: 'Sophisticated guest bathroom with modern fixtures and clean lines.',
height: 450, height: 450,
width: 300 width: 300,
}, },
{ {
type: 'image', type: 'image',
@@ -77,7 +78,7 @@ export const mediaItems: MediaItem[] = [
height: 450, height: 450,
width: 300, width: 300,
loading: 'eager', loading: 'eager',
fetchpriority: 'high' fetchpriority: 'high',
}, },
{ {
type: 'image', type: 'image',
@@ -85,7 +86,7 @@ export const mediaItems: MediaItem[] = [
caption: 'Everyday luxury, right at home—your ensuite oasis.', caption: 'Everyday luxury, right at home—your ensuite oasis.',
alt: 'Elegant ensuite with sleek fixtures and stylish decor.', alt: 'Elegant ensuite with sleek fixtures and stylish decor.',
height: 200, height: 200,
width: 300 width: 300,
}, },
{ {
type: 'image', type: 'image',
@@ -93,15 +94,15 @@ export const mediaItems: MediaItem[] = [
caption: 'Laundry day reimagined—functional never looked so good.', caption: 'Laundry day reimagined—functional never looked so good.',
alt: 'Modern laundry room with washer, dryer, and organized storage.', alt: 'Modern laundry room with washer, dryer, and organized storage.',
height: 450, height: 450,
width: 300 width: 300,
}, },
{ {
type: 'image', type: 'image',
name: 'coat_closet', name: 'coat_closet',
caption: 'Organized and chic—your entryway\'s best friend.', caption: "Organized and chic—your entryway's best friend.",
alt: 'Convenient coat closet with tidy storage solutions.', alt: 'Convenient coat closet with tidy storage solutions.',
height: 200, height: 200,
width: 300 width: 300,
}, },
{ {
type: 'image', type: 'image',
@@ -109,7 +110,7 @@ export const mediaItems: MediaItem[] = [
caption: 'Outdoor comfort, just steps away—morning coffee optional.', caption: 'Outdoor comfort, just steps away—morning coffee optional.',
alt: 'Sunny deck with cozy seating and pleasant outdoor views.', alt: 'Sunny deck with cozy seating and pleasant outdoor views.',
height: 450, height: 450,
width: 300 width: 300,
}, },
{ {
type: 'image', type: 'image',
@@ -117,7 +118,7 @@ export const mediaItems: MediaItem[] = [
caption: 'Your fresh-air escape—ideal for relaxing evenings.', caption: 'Your fresh-air escape—ideal for relaxing evenings.',
alt: 'Comfortable deck area perfect for unwinding or entertaining.', alt: 'Comfortable deck area perfect for unwinding or entertaining.',
height: 200, height: 200,
width: 300 width: 300,
}, },
{ {
type: 'image', type: 'image',
@@ -125,23 +126,25 @@ export const mediaItems: MediaItem[] = [
caption: 'Curb appeal perfected—your new favorite place starts here.', caption: 'Curb appeal perfected—your new favorite place starts here.',
alt: 'Attractive home exterior with inviting architecture.', alt: 'Attractive home exterior with inviting architecture.',
height: 200, height: 200,
width: 300 width: 300,
}, },
{ {
type: 'image', type: 'image',
name: 'backyard_parking', name: 'backyard_parking',
caption: 'Convenience meets privacy—your personal backyard parking spot.', caption:
'Convenience meets privacy—your personal backyard parking spot.',
alt: 'Private backyard parking area offering secure convenience.', alt: 'Private backyard parking area offering secure convenience.',
height: 200, height: 200,
width: 300 width: 300,
}, },
{ {
type: 'image', type: 'image',
name: 'office_fitness_guest_1', name: 'office_fitness_guest_1',
caption: 'Productivity zone meets fitness corner—multitasking done right.', caption:
'Productivity zone meets fitness corner—multitasking done right.',
alt: 'Dual-purpose room featuring office setup and fitness equipment.', alt: 'Dual-purpose room featuring office setup and fitness equipment.',
height: 200, height: 200,
width: 300 width: 300,
}, },
{ {
type: 'image', type: 'image',
@@ -149,7 +152,7 @@ export const mediaItems: MediaItem[] = [
caption: 'Work, workout, or unwind—the room of endless possibilities.', caption: 'Work, workout, or unwind—the room of endless possibilities.',
alt: 'Versatile office and fitness area with modern amenities.', alt: 'Versatile office and fitness area with modern amenities.',
height: 200, height: 200,
width: 300 width: 300,
}, },
{ {
type: 'image', type: 'image',
@@ -157,7 +160,7 @@ export const mediaItems: MediaItem[] = [
caption: 'Stay focused or get fit—you decide.', caption: 'Stay focused or get fit—you decide.',
alt: 'Functional space combining a workspace and home fitness area.', alt: 'Functional space combining a workspace and home fitness area.',
height: 200, height: 200,
width: 300 width: 300,
}, },
{ {
type: 'image', type: 'image',
@@ -165,14 +168,15 @@ export const mediaItems: MediaItem[] = [
caption: 'Room for every routine—your workspace meets wellness.', caption: 'Room for every routine—your workspace meets wellness.',
alt: 'Stylish office area seamlessly integrated with fitness features.', alt: 'Stylish office area seamlessly integrated with fitness features.',
height: 200, height: 200,
width: 300 width: 300,
}, },
{ {
type: 'video', type: 'video',
name: 'tour', name: 'tour',
caption: "Take the scenic route—explore your the home's highlights with a virtual walkthrough.", caption:
"Take the scenic route—explore your the home's highlights with a virtual walkthrough.",
alt: 'Video tour showcasing the property.', alt: 'Video tour showcasing the property.',
height: 534, height: 534,
width: 300 width: 300,
} },
]; ];

View 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);
},
};

View File

@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import '../app.css'; import '../app.css';
let { children } = $props();
</script> </script>
<slot /> {@render children()}

View File

@@ -1 +1,2 @@
export const prerender = true; export const prerender = true;
export const ssr = true;

View File

@@ -1,16 +1,19 @@
<script lang="ts"> <script lang="ts">
import SiteHeader from '$lib/components/SiteHeader.svelte'; import type { MediaItem } from '$lib/media.js';
import Lightbox from '$lib/components/Lightbox.svelte';
import GalleryFigure from '$lib/components/GalleryFigure.svelte'; import GalleryFigure from '$lib/components/GalleryFigure.svelte';
import SiteHeader from '$lib/components/SiteHeader.svelte';
interface Props { interface Props {
data: { mediaItems: import('$lib/media.js').MediaItem[] }; data: { mediaItems: MediaItem[] };
} }
let { data }: Props = $props(); let { data }: Props = $props();
const title = '64 Armandine St #3 Boston, Massachusetts'; const title = '64 Armandine St #3 Boston, Massachusetts';
const description = const description =
'An inviting blend of comfort and curated art—relaxation guaranteed.'; 'An inviting blend of comfort and curated art—relaxation guaranteed.';
const canonical = 'https://armandine.example.com'; // replace with real URL const canonical = 'https://armandine.mifi.holdings/';
const gaId = 'G-QZGFK4MDT4'; const gaId = 'G-QZGFK4MDT4';
const jsonLd = { const jsonLd = {
@@ -23,8 +26,18 @@
streetAddress: '64 Armandine St #3', streetAddress: '64 Armandine St #3',
addressLocality: 'Boston', addressLocality: 'Boston',
addressRegion: 'MA', addressRegion: 'MA',
addressCountry: 'US' addressCountry: 'US',
} },
};
let showPicture = $state<MediaItem | null>(null);
const showLightbox = (item: MediaItem) => {
showPicture = item;
};
const onClose = () => {
showPicture = null;
}; };
</script> </script>
@@ -38,14 +51,27 @@
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:url" content={canonical} /> <meta property="og:url" content={canonical} />
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png" /> <link
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16x16.png" /> 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/x-icon" href="/assets/favicon.ico" />
<link rel="preload" href="/assets/js/script.js" as="script" /> <link rel="preload" href="/assets/js/script.js" as="script" />
<script defer src="/assets/js/script.js"></script> <script defer src="/assets/js/script.js"></script>
<script async src="https://www.googletagmanager.com/gtag/js?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> <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>`}
@@ -54,15 +80,9 @@
<SiteHeader /> <SiteHeader />
<main> <main>
<section id="gallery" class="gallery-grid"> <section id="gallery" class="gallery-grid">
{#each data.mediaItems as item} {#each data.mediaItems as item, index (item.name)}
<GalleryFigure {item} /> <GalleryFigure {item} {index} {showLightbox} />
{/each} {/each}
</section> </section>
</main> </main>
<Lightbox item={showPicture} {onClose} />
<div id="lightbox" aria-hidden="true">
<button id="lb-close" aria-label="Close">&times;</button>
<div id="lb-content"></div>
<p id="lb-caption"></p>
</div>

View File

@@ -0,0 +1 @@
{}

View File

@@ -1,108 +1,13 @@
// --- theme toggle ---
const toggle = document.getElementById('theme-toggle');
const root = document.documentElement; 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; const sysDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (saved === 'true') {
root.classList.add('dark'); if (saved) {
root.classList.remove('light'); root.setAttribute('data-theme', saved);
} else if (saved === 'false') {
root.classList.add('light');
root.classList.remove('dark');
} else { } else {
if (sysDark) { if (sysDark) {
root.classList.add('dark'); root.setAttribute('data-theme', 'dark');
root.classList.remove('light');
} else { } else {
root.classList.add('light'); root.setAttribute('data-theme', 'light');
root.classList.remove('dark');
} }
} }
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')
});
}
});

View File

@@ -0,0 +1,4 @@
WEBVTT
00:00:00.000 --> 00:00:02.000
No audio. Silent video tour.