diff --git a/package.json b/package.json
index 416ff28..b20fca3 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c5262d6..d85fd10 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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:
diff --git a/scripts/externalize-bootstrap.js b/scripts/externalize-bootstrap.js
new file mode 100644
index 0000000..09cdbe5
--- /dev/null
+++ b/scripts/externalize-bootstrap.js
@@ -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 containing __sveltekit_, minifies it, writes to _app/immutable/entry/bootstrap.js,
+ * and replaces the inline script with that contains __sveltekit_
+function findInlineBootstrap(html) {
+ const scriptOpen = html.indexOf('', scriptOpen);
+ if (scriptClose === -1) return null;
+ const content = html.slice(scriptOpen + ''.length };
+}
+
+const SCRIPT_TAG = ``;
+
+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);
+});
diff --git a/src/app.css b/src/app.css
index 363e732..494a5e3 100644
--- a/src/app.css
+++ b/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;
+ }
}
diff --git a/src/app.d.ts b/src/app.d.ts
index da08e6d..d76242a 100644
--- a/src/app.d.ts
+++ b/src/app.d.ts
@@ -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 {};
diff --git a/src/lib/components/GalleryFigure.svelte b/src/lib/components/GalleryFigure.svelte
index 5383b29..34cd1db 100644
--- a/src/lib/components/GalleryFigure.svelte
+++ b/src/lib/components/GalleryFigure.svelte
@@ -1,83 +1,100 @@
-
- showLightbox(item)}
+ onkeydown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ showLightbox(item);
+ }
+ }}
>
-
- {#each [
- { bp: 'desktop', minWidth: 1024 },
- { bp: 'tablet', minWidth: 768 },
- { bp: 'mobile', minWidth: 0 }
- ] as breakpoint}
-
- {/each}
-
2 ? item.loading ?? 'lazy' : undefined}
- fetchpriority={item.fetchpriority ?? undefined}
- />
-
- {item.caption}
-
+
+
+ {#each [{ bp: 'desktop', minWidth: 1024 }, { bp: 'tablet', minWidth: 768 }, { bp: 'mobile', minWidth: 0 }] as breakpoint}
+
+ {/each}
+
2
+ ? (item.loading ?? 'lazy')
+ : undefined}
+ fetchpriority={item.fetchpriority ?? undefined}
+ />
+
+ {item.caption}
+
+
diff --git a/src/lib/components/Lightbox.svelte b/src/lib/components/Lightbox.svelte
new file mode 100644
index 0000000..f132351
--- /dev/null
+++ b/src/lib/components/Lightbox.svelte
@@ -0,0 +1,140 @@
+
+
+
+
+
diff --git a/src/lib/components/SiteHeader.svelte b/src/lib/components/SiteHeader.svelte
index 5afe330..3ef2a70 100644
--- a/src/lib/components/SiteHeader.svelte
+++ b/src/lib/components/SiteHeader.svelte
@@ -1,30 +1,47 @@
+
+
diff --git a/src/lib/media.ts b/src/lib/media.ts
index 8636f9f..7d41b3d 100644
--- a/src/lib/media.ts
+++ b/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,
+ },
];
diff --git a/src/lib/stores/theme.svelte.ts b/src/lib/stores/theme.svelte.ts
new file mode 100644
index 0000000..3de7dfc
--- /dev/null
+++ b/src/lib/stores/theme.svelte.ts
@@ -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);
+ },
+};
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index f87b35e..8921562 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -1,5 +1,7 @@
-
+{@render children()}
diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts
index 189f71e..0df44ad 100644
--- a/src/routes/+layout.ts
+++ b/src/routes/+layout.ts
@@ -1 +1,2 @@
export const prerender = true;
+export const ssr = true;
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 3ac0ada..2a7967e 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -1,68 +1,88 @@
- {title}
-
-
-
-
-
-
-
+ {title}
+
+
+
+
+
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
- {@html ``}
+ {@html ``}
-
- {#each data.mediaItems as item}
-
- {/each}
-
+
+ {#each data.mediaItems as item, index (item.name)}
+
+ {/each}
+
-
-
-
+
diff --git a/src/routes/+page.ts b/src/routes/+page.ts
index e2717c6..074d678 100644
--- a/src/routes/+page.ts
+++ b/src/routes/+page.ts
@@ -1,5 +1,5 @@
import { mediaItems } from '$lib/media.js';
export function load() {
- return { mediaItems };
+ return { mediaItems };
}
diff --git a/static/.well-known/appspecific/com.chrome.devtools.json b/static/.well-known/appspecific/com.chrome.devtools.json
new file mode 100644
index 0000000..9e26dfe
--- /dev/null
+++ b/static/.well-known/appspecific/com.chrome.devtools.json
@@ -0,0 +1 @@
+{}
\ No newline at end of file
diff --git a/static/assets/js/script.js b/static/assets/js/script.js
index 8924b60..fac19d1 100644
--- a/static/assets/js/script.js
+++ b/static/assets/js/script.js
@@ -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')
- });
- }
-});
diff --git a/static/assets/media/videos/tour-captions.vtt b/static/assets/media/videos/tour-captions.vtt
new file mode 100644
index 0000000..0e770e0
--- /dev/null
+++ b/static/assets/media/videos/tour-captions.vtt
@@ -0,0 +1,4 @@
+WEBVTT
+
+00:00:00.000 --> 00:00:02.000
+No audio. Silent video tour.