This commit is contained in:
@@ -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
15
pnpm-lock.yaml
generated
@@ -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:
|
||||||
|
|||||||
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 {
|
: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
14
src/app.d.ts
vendored
@@ -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 {};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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">
|
<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>
|
||||||
|
|||||||
350
src/lib/media.ts
350
src/lib/media.ts
@@ -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,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
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">
|
<script lang="ts">
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<slot />
|
{@render children()}
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export const prerender = true;
|
export const prerender = true;
|
||||||
|
export const ssr = true;
|
||||||
|
|||||||
@@ -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">×</button>
|
|
||||||
<div id="lb-content"></div>
|
|
||||||
<p id="lb-caption"></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
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 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')
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
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