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

@@ -1,99 +1,67 @@
:root { :root {
--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) {
:root { :root {
--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 {
--bg: #111; &[data-theme='dark'] {
--fg: #eee; --bg: #111;
--accent: #46c; --fg: #eee;
} --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 {
margin: 0; margin: 0;
font-family: sans-serif; font-family: sans-serif;
background-color: var(--bg); background-color: var(--bg);
color: var(--fg); color: var(--fg);
} }
.lightbox-open { .lightbox-open {
overflow: hidden; overflow: hidden;
} }
.gallery-grid { .gallery-grid {
column-count: 1; column-count: 1;
column-gap: 1rem; column-gap: 1rem;
padding: 1rem; padding: 1rem;
} }
@media (width >= 768px) { @media (width >= 768px) {
.gallery-grid { .gallery-grid {
column-count: 2; column-count: 2;
} }
} }
@media (width >= 1024px) { @media (width >= 1024px) {
.gallery-grid { .gallery-grid {
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;
} }

14
src/app.d.ts vendored
View File

@@ -1,13 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts // See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces // for information about these interfaces
declare global { declare global {
namespace App { namespace App {
// interface Error {} // interface Error {}
// interface Locals {} // interface Locals {}
// interface PageData {} // interface PageData {}
// interface PageState {} // interface PageState {}
// interface Platform {} // interface Platform {}
} }
} }
export {}; export {};

View File

@@ -1,83 +1,100 @@
<script lang="ts"> <script lang="ts">
import type { MediaItem } from '$lib/media.js'; import type { MediaItem } from '$lib/media.js';
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);
}
}}
> >
<picture> <figure>
{#each [ <picture>
{ bp: 'desktop', minWidth: 1024 }, {#each [{ bp: 'desktop', minWidth: 1024 }, { bp: 'tablet', minWidth: 768 }, { bp: 'mobile', minWidth: 0 }] as breakpoint}
{ bp: 'tablet', minWidth: 768 }, <source
{ bp: 'mobile', minWidth: 0 } media="(min-width:{breakpoint.minWidth}px)"
] as breakpoint} srcset={item.type === 'image'
<source ? `/assets/media/${breakpoint.bp}/${item.name}@1x.webp 1x, /assets/media/${breakpoint.bp}/${item.name}.webp 2x`
media="(min-width:{breakpoint.minWidth}px)" : `/assets/media/${breakpoint.bp}/${item.name}_still@1x.webp 1x, /assets/media/${breakpoint.bp}/${item.name}_still.webp 2x`}
srcset={item.type === 'image' />
? `/assets/media/${breakpoint.bp}/${item.name}@1x.webp 1x, /assets/media/${breakpoint.bp}/${item.name}.webp 2x` {/each}
: `/assets/media/${breakpoint.bp}/${item.name}_still@1x.webp 1x, /assets/media/${breakpoint.bp}/${item.name}_still.webp 2x`} <img
/> src="/assets/media/thumbnail/{item.name}.webp"
{/each} alt={item.alt.replace(/['']/g, '')}
<img height={item.height ?? undefined}
src="/assets/media/thumbnail/{item.name}.webp" width={item.width ?? undefined}
alt={item.alt.replace(/['']/g, '')} loading={index && index > 2
height={item.height ?? undefined} ? (item.loading ?? 'lazy')
width={item.width ?? undefined} : 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;
margin: 0 0 1rem; background: none;
position: relative; border: none;
cursor: pointer; color: inherit;
cursor: pointer;
& img { font: inherit;
height: auto; margin: 0 0 1rem;
width: 100%; position: relative;
display: block; padding: 0;
border-radius: 8px; text-align: left;
} width: 100%;
& 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 { & figure {
outline: 2px solid var(--accent); margin: 0;
} }
& img {
height: auto;
width: 100%;
display: block;
border-radius: 8px;
}
&:focus, & figcaption {
&:hover { position: absolute;
& figcaption { bottom: 0;
opacity: 1; 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,
&:hover {
& figcaption {
opacity: 1;
}
}
}
</style> </style>

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,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"> <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"
</button> class="emoji-button"
<button id="theme-toggle" class="emoji-button" aria-label="Toggle light/dark theme"> aria-label="Show video tour"
🌓 >
</button> 🎥
</div> </button>
<button
id="theme-toggle"
class="emoji-button"
aria-label="Toggle light/dark theme"
onclick={toggleTheme}
>
🌓
</button>
</div>
</header> </header>
<style> <style>
.site-header { .site-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 1rem; padding: 1rem;
background: var(--bg); background: var(--bg);
border-bottom: 1px solid #ccc; border-bottom: 1px solid #ccc;
} }
.emoji-button { .emoji-button {
background: none; background: none;
border: none; border: none;
font-size: 1.5rem; font-size: 1.5rem;
cursor: pointer; cursor: pointer;
color: var(--accent); color: var(--accent);
} }
</style> </style>

View File

@@ -1,178 +1,182 @@
export interface MediaItem { export interface MediaItem {
type: 'image' | 'video'; type: 'image' | 'video';
name: string; name: string;
caption: string; caption: string;
alt: string; alt: string;
height?: number; height?: number;
width?: number; width?: number;
loading?: 'lazy' | 'eager'; loading?: 'lazy' | 'eager';
fetchpriority?: 'high' | 'low' | 'auto'; fetchpriority?: 'high' | 'low' | 'auto';
} }
export const mediaItems: MediaItem[] = [ 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:
alt: 'Sunny living room with stylish seating and vibrant artwork.', 'An inviting blend of comfort and curated art—relaxation guaranteed.',
height: 200, alt: 'Sunny living room with stylish seating and vibrant artwork.',
width: 300, height: 200,
loading: 'eager', width: 300,
fetchpriority: 'high' fetchpriority: 'high',
}, },
{ {
type: 'image', type: 'image',
name: 'living_room_2', name: 'living_room_2',
caption: 'Relaxation elevated—your stylish living space awaits.', caption: 'Relaxation elevated—your stylish living space awaits.',
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:
alt: 'Modern kitchen showcasing sleek appliances and contemporary design.', 'The culinary stage is set—snacking encouraged, style required.',
height: 200, alt: 'Modern kitchen showcasing sleek appliances and contemporary design.',
width: 300 height: 200,
}, width: 300,
{ },
type: 'image', {
name: 'bedroom_suite_1', type: 'image',
caption: 'A bedroom suite designed to make snoozing irresistible.', name: 'bedroom_suite_1',
alt: 'Inviting bedroom suite with cozy bedding and warm lighting.', caption: 'A bedroom suite designed to make snoozing irresistible.',
height: 200, alt: 'Inviting bedroom suite with cozy bedding and warm lighting.',
width: 300 height: 200,
}, width: 300,
{ },
type: 'image', {
name: 'bedroom_suite_2', type: 'image',
caption: 'Style meets comfort—sleeping in has never been easier.', name: 'bedroom_suite_2',
alt: 'Comfortable bedroom suite with elegant decor and soft tones.', caption: 'Style meets comfort—sleeping in has never been easier.',
height: 200, alt: 'Comfortable bedroom suite with elegant decor and soft tones.',
width: 300 height: 200,
}, width: 300,
{ },
type: 'image', {
name: 'bedroom_suite_3', type: 'image',
caption: 'Where dreams get stylish—a bedroom that feels like home.', name: 'bedroom_suite_3',
alt: 'Welcoming bedroom with soothing colors and inviting ambiance.', caption: 'Where dreams get stylish—a bedroom that feels like home.',
height: 200, alt: 'Welcoming bedroom with soothing colors and inviting ambiance.',
width: 300 height: 200,
}, width: 300,
{ },
type: 'image', {
name: 'guest_bath', type: 'image',
caption: 'Your personal spa experience—right down the hall.', name: 'guest_bath',
alt: 'Sophisticated guest bathroom with modern fixtures and clean lines.', caption: 'Your personal spa experience—right down the hall.',
height: 450, alt: 'Sophisticated guest bathroom with modern fixtures and clean lines.',
width: 300 height: 450,
}, width: 300,
{ },
type: 'image', {
name: 'onsuite_1', type: 'image',
caption: 'Luxury meets practicality—your private ensuite awaits.', name: 'onsuite_1',
alt: 'Private ensuite bathroom featuring contemporary design and premium finishes.', caption: 'Luxury meets practicality—your private ensuite awaits.',
height: 450, alt: 'Private ensuite bathroom featuring contemporary design and premium finishes.',
width: 300, height: 450,
loading: 'eager', width: 300,
fetchpriority: 'high' loading: 'eager',
}, fetchpriority: 'high',
{ },
type: 'image', {
name: 'onsuite_2', type: 'image',
caption: 'Everyday luxury, right at home—your ensuite oasis.', name: 'onsuite_2',
alt: 'Elegant ensuite with sleek fixtures and stylish decor.', caption: 'Everyday luxury, right at home—your ensuite oasis.',
height: 200, alt: 'Elegant ensuite with sleek fixtures and stylish decor.',
width: 300 height: 200,
}, width: 300,
{ },
type: 'image', {
name: 'laundry', type: 'image',
caption: 'Laundry day reimagined—functional never looked so good.', name: 'laundry',
alt: 'Modern laundry room with washer, dryer, and organized storage.', caption: 'Laundry day reimagined—functional never looked so good.',
height: 450, alt: 'Modern laundry room with washer, dryer, and organized storage.',
width: 300 height: 450,
}, width: 300,
{ },
type: 'image', {
name: 'coat_closet', type: 'image',
caption: 'Organized and chic—your entryway\'s best friend.', name: 'coat_closet',
alt: 'Convenient coat closet with tidy storage solutions.', caption: "Organized and chic—your entryway's best friend.",
height: 200, alt: 'Convenient coat closet with tidy storage solutions.',
width: 300 height: 200,
}, width: 300,
{ },
type: 'image', {
name: 'deck_1', type: 'image',
caption: 'Outdoor comfort, just steps away—morning coffee optional.', name: 'deck_1',
alt: 'Sunny deck with cozy seating and pleasant outdoor views.', caption: 'Outdoor comfort, just steps away—morning coffee optional.',
height: 450, alt: 'Sunny deck with cozy seating and pleasant outdoor views.',
width: 300 height: 450,
}, width: 300,
{ },
type: 'image', {
name: 'deck_2', type: 'image',
caption: 'Your fresh-air escape—ideal for relaxing evenings.', name: 'deck_2',
alt: 'Comfortable deck area perfect for unwinding or entertaining.', caption: 'Your fresh-air escape—ideal for relaxing evenings.',
height: 200, alt: 'Comfortable deck area perfect for unwinding or entertaining.',
width: 300 height: 200,
}, width: 300,
{ },
type: 'image', {
name: 'exterior', type: 'image',
caption: 'Curb appeal perfected—your new favorite place starts here.', name: 'exterior',
alt: 'Attractive home exterior with inviting architecture.', caption: 'Curb appeal perfected—your new favorite place starts here.',
height: 200, alt: 'Attractive home exterior with inviting architecture.',
width: 300 height: 200,
}, width: 300,
{ },
type: 'image', {
name: 'backyard_parking', type: 'image',
caption: 'Convenience meets privacy—your personal backyard parking spot.', name: 'backyard_parking',
alt: 'Private backyard parking area offering secure convenience.', caption:
height: 200, 'Convenience meets privacy—your personal backyard parking spot.',
width: 300 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.', type: 'image',
alt: 'Dual-purpose room featuring office setup and fitness equipment.', name: 'office_fitness_guest_1',
height: 200, caption:
width: 300 'Productivity zone meets fitness corner—multitasking done right.',
}, alt: 'Dual-purpose room featuring office setup and fitness equipment.',
{ height: 200,
type: 'image', width: 300,
name: 'office_fitness_guest_2', },
caption: 'Work, workout, or unwind—the room of endless possibilities.', {
alt: 'Versatile office and fitness area with modern amenities.', type: 'image',
height: 200, name: 'office_fitness_guest_2',
width: 300 caption: 'Work, workout, or unwind—the room of endless possibilities.',
}, alt: 'Versatile office and fitness area with modern amenities.',
{ height: 200,
type: 'image', width: 300,
name: 'office_fitness_guest_3', },
caption: 'Stay focused or get fit—you decide.', {
alt: 'Functional space combining a workspace and home fitness area.', type: 'image',
height: 200, name: 'office_fitness_guest_3',
width: 300 caption: 'Stay focused or get fit—you decide.',
}, alt: 'Functional space combining a workspace and home fitness area.',
{ height: 200,
type: 'image', width: 300,
name: 'office_fitness_guest_4', },
caption: 'Room for every routine—your workspace meets wellness.', {
alt: 'Stylish office area seamlessly integrated with fitness features.', type: 'image',
height: 200, name: 'office_fitness_guest_4',
width: 300 caption: 'Room for every routine—your workspace meets wellness.',
}, alt: 'Stylish office area seamlessly integrated with fitness features.',
{ height: 200,
type: 'video', width: 300,
name: 'tour', },
caption: "Take the scenic route—explore your the home's highlights with a virtual walkthrough.", {
alt: 'Video tour showcasing the property.', type: 'video',
height: 534, name: 'tour',
width: 300 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,
},
]; ];

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,68 +1,88 @@
<script lang="ts"> <script lang="ts">
import SiteHeader from '$lib/components/SiteHeader.svelte'; import type { MediaItem } from '$lib/media.js';
import GalleryFigure from '$lib/components/GalleryFigure.svelte'; import Lightbox from '$lib/components/Lightbox.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();
const title = '64 Armandine St #3 Boston, Massachusetts'; let { data }: Props = $props();
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';
const jsonLd = { const title = '64 Armandine St #3 Boston, Massachusetts';
'@context': 'https://schema.org', const description =
'@type': 'Place', 'An inviting blend of comfort and curated art—relaxation guaranteed.';
name: title, const canonical = 'https://armandine.mifi.holdings/';
description, const gaId = 'G-QZGFK4MDT4';
address: {
'@type': 'PostalAddress', const jsonLd = {
streetAddress: '64 Armandine St #3', '@context': 'https://schema.org',
addressLocality: 'Boston', '@type': 'Place',
addressRegion: 'MA', name: title,
addressCountry: 'US' 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> </script>
<svelte:head> <svelte:head>
<title>{title}</title> <title>{title}</title>
<meta name="description" content={description} /> <meta name="description" content={description} />
<meta name="viewport" content="width=device-width,initial-scale=1" /> <meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="canonical" href={canonical} /> <link rel="canonical" href={canonical} />
<meta property="og:title" content={title} /> <meta property="og:title" content={title} />
<meta property="og:description" content={description} /> <meta property="og:description" content={description} />
<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"
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" /> 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" /> <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
<script defer src="/assets/js/ga-init.js" data-ga-id={gaId}></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> </svelte:head>
<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

@@ -1,5 +1,5 @@
import { mediaItems } from '$lib/media.js'; import { mediaItems } from '$lib/media.js';
export function load() { export function load() {
return { mediaItems }; return { mediaItems };
} }

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.