Compare commits

...

4 Commits

Author SHA1 Message Date
6734dfa51e Merge pull request 'Svelte conversion — quick and dirty' (#1) from sveltification into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
Reviewed-on: #1
2026-02-16 02:08:14 +00:00
9f74726236 Add test build to CI pipeline
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
2026-02-15 23:05:31 -03:00
4f863e5686 Finished the Sveltification
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
2026-02-15 23:01:16 -03:00
99cb89d1e8 Svelte conversion — quick and dirty
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
2026-02-15 14:10:57 -03:00
203 changed files with 3430 additions and 1218 deletions

View File

@@ -1,40 +1,47 @@
{
"name": "Armandine",
"image": "mcr.microsoft.com/devcontainers/javascript-node:1-22-bookworm",
"features": {
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}
},
"postCreateCommand": "corepack enable && corepack prepare pnpm@10.29.2 --activate && pnpm install",
"forwardPorts": [3000, 80],
"portsAttributes": {
"3000": {
"label": "Static site (pnpm serve)",
"onAutoForward": "notify"
},
"80": {
"label": "Nginx (when running container)",
"onAutoForward": "silent"
}
},
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"stylelint.vscode-stylelint"
],
"settings": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[css]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"[html]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.fixAll.stylelint": "explicit"
}
}
}
},
"remoteUser": "node"
"name": "Armandine",
"image": "mcr.microsoft.com/devcontainers/javascript-node:1-22-bookworm",
"features": {
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}
},
"postCreateCommand": "corepack enable && corepack prepare pnpm@10.29.2 --activate && pnpm install",
"forwardPorts": [5173, 4173, 80],
"portsAttributes": {
"80": {
"label": "Nginx (when running container)",
"onAutoForward": "silent"
},
"4173": {
"label": "Preview build (pnpm preview)",
"onAutoForward": "silent"
},
"5173": {
"label": "SvelteKit dev (pnpm dev)",
"onAutoForward": "notify"
}
},
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"stylelint.vscode-stylelint",
"svelte.svelte-vscode"
],
"settings": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[css]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"[svelte]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"[html]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.fixAll.stylelint": "explicit"
}
}
}
},
"remoteUser": "node"
}

5
.gitignore vendored
View File

@@ -28,7 +28,10 @@ Thumbs.db
*.swo
*~
# Build output (if added later)
# SvelteKit
.svelte-kit
# Build output
dist
build
.next

View File

@@ -1,15 +1,11 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 4,
"trailingComma": "all",
"overrides": [
{
"files": "*.yml",
"options": {
"tabWidth": 4,
"proseWrap": "preserve"
}
}
]
"semi": true,
"singleQuote": true,
"tabWidth": 4,
"trailingComma": "all",
"plugins": ["prettier-plugin-svelte"],
"overrides": [
{ "files": "*.yml", "options": { "tabWidth": 4, "proseWrap": "preserve" } },
{ "files": "*.svelte", "options": { "parser": "svelte" } }
]
}

View File

@@ -12,34 +12,6 @@ steps:
- corepack prepare pnpm@10.29.2 --activate
- pnpm install --frozen-lockfile
- name: lint
image: node:22-alpine
commands:
- corepack enable
- corepack prepare pnpm@10.29.2 --activate
- pnpm install --frozen-lockfile
- pnpm lint
depends_on:
- install
- name: Send Lint Status Notification (failure)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_tests_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Lint failure 💩"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- lint
when:
- status: [failure]
- name: format check
image: node:22-alpine
commands:
@@ -68,6 +40,90 @@ steps:
when:
- status: [failure]
- name: lint
image: node:22-alpine
commands:
- corepack enable
- corepack prepare pnpm@10.29.2 --activate
- pnpm install --frozen-lockfile
- pnpm lint
depends_on:
- format check
- name: Send Lint Status Notification (failure)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_tests_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Lint failure 💩"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- lint
when:
- status: [failure]
- name: check
image: node:22-alpine
commands:
- corepack enable
- corepack prepare pnpm@10.29.2 --activate
- pnpm install --frozen-lockfile
- pnpm check
depends_on:
- lint
- name: Send Svelte Check Status Notification (failure)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_tests_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Svelte check failure 💩"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- check
when:
- status: [failure]
- name: build
image: node:22-alpine
commands:
- corepack enable
- corepack prepare pnpm@10.29.2 --activate
- pnpm install --frozen-lockfile
- pnpm build
depends_on:
- check
- name: Send Build Status Notification (failure)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_tests_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Build failure 💩"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- build
when:
- status: [failure]
- name: Send CI Pipeline Status Notification (success)
image: curlimages/curl
environment:
@@ -83,7 +139,9 @@ steps:
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- install
- lint
- format check
- lint
- check
- build
when:
- status: [success]

53
AGENTS.md Normal file
View File

@@ -0,0 +1,53 @@
# AGENTS.md guidance for LLM agents
This file helps LLM agents work with the Armandine codebase without introducing antipatterns.
## Stack and goals
- **Svelte 5** + **SvelteKit** with **TypeScript**. The site is a **single pre-rendered page** (no SSR, no backend).
- **adapter-static**: the app is built to static HTML/JS/CSS in `build/`. The gallery is **rendered at build time** from `src/lib/media.ts`; the HTML already contains the full gallery. Lightbox and theme toggle are handled by a **client script** (`static/assets/js/script.js`) that binds to the pre-rendered DOM.
- **PostCSS**: nesting and CSS level 2; component-scoped `<style>` in Svelte where possible; global styles in `src/app.css`.
- **Critical CSS**: after `vite build`, `scripts/critical-css.js` runs Beasties to inline critical CSS into the built HTML.
## Structure
| What | Where |
|------|--------|
| Routes / page | `src/routes/` (`+page.svelte`, `+page.ts`, `+layout.svelte`, `+layout.ts`) |
| Components | `src/lib/components/` (e.g. `GalleryFigure.svelte`, `SiteHeader.svelte`) |
| Media data (build-time gallery) | `src/lib/media.ts` typed array; loaded in `+page.ts` and passed to the page |
| Client script (lightbox, theme) | `static/assets/js/script.js` not part of Svelte bundle; preloaded in head |
| GA init | `static/assets/js/ga-init.js` |
| Static assets (favicons, media files) | `static/assets/` (media: `desktop/`, `tablet/`, `mobile/`, `thumbnail/`, `videos/`) |
| Global CSS | `src/app.css` (imported in `+layout.svelte`) |
| Config | `svelte.config.js`, `vite.config.ts`, `tsconfig.json`, `postcss.config.js` |
## Conventions
- Use **Svelte 5** patterns (e.g. `$props()`, runes). Use **TypeScript** for `src/lib` and route logic.
- **Gallery**: Rendered at build time via `GalleryFigure` and `data.mediaItems` from `+page.ts`. Do not move gallery rendering to the client.
- **Lightbox and theme**: Implemented in `static/assets/js/script.js`; they rely on the pre-rendered DOM (e.g. `.gallery-item`, `#lightbox`, `#lb-content`). Keep them in that script; do not reimplement in Svelte for “consistency” (the plan keeps them in JS for optimal loading and preload).
- **Head**: Metadata, JSON-LD, favicons, preload of `script.js`, and GA scripts are set in `+page.svelte`s `<svelte:head>`.
- **CSS**: Prefer component-scoped `<style>` in Svelte components; use `src/app.css` only for `:root`, `body`, and shared/global rules (e.g. lightbox, which is targeted by the client script).
- **Build**: `pnpm build` = `vite build` then `node scripts/critical-css.js`. Output is `build/`. Dockerfile copies `build/` into the image.
## Build pipeline and CI
- **Local:** `pnpm dev` (dev server), `pnpm build` (production build), `pnpm preview` (serve `build/`), `pnpm check` (Svelte + TypeScript check).
- **CI (Woodpecker):** `ci` runs lint, format:check, and **check** (Svelte/TS). `build` runs `pnpm build` then `docker build` (image uses `build/`).
## Antipatterns to avoid
- **Do not** add a backend, API routes, or SSR for this static site.
- **Do not** move lightbox or theme logic into Svelte components; they stay in `static/assets/js/script.js` and attach to the pre-rendered DOM.
- **Do not** add unscoped global CSS for component-specific styles; use component `<style>` or the existing `app.css` sections.
- **Do not** introduce dynamic routes or server-dependent behavior that would break static export (adapter-static).
- **Do not** remove or bypass the Beasties critical-CSS step without replacing it with an equivalent (e.g. another inlining strategy).
## How to add features
- **New media item:** Add an entry to the `mediaItems` array in `src/lib/media.ts` (type, name, caption, alt, dimensions, loading/fetchpriority as needed). Ensure the corresponding files exist under `static/assets/media/` (desktop, tablet, mobile, thumbnail; videos in `videos/`).
- **New component:** Add a `.svelte` file under `src/lib/components/` (or a subfolder). Use component-scoped `<style>` and PostCSS will process it. Export and use in the appropriate route or parent component.
- **New page:** Add a route under `src/routes/` (e.g. `src/routes/about/+page.svelte` and `+page.ts` if needed). With `prerender = true` in the root layout, all pages are pre-rendered.
- **Change metadata or JSON-LD:** Edit the `<svelte:head>` block in `src/routes/+page.svelte` (or the relevant page). Update the `jsonLd` object and meta tags as needed.
- **Change client behavior (lightbox/theme):** Edit `static/assets/js/script.js`. Ensure the script still targets the same DOM structure (e.g. `.gallery-item`, `data-name`, `data-type`, `data-caption`, `#lightbox`, `#lb-content`, `#lb-caption`, `#lb-close`, `#show_video`, `#theme-toggle`).

View File

@@ -1,4 +1,4 @@
FROM nginx:alpine
COPY nginx/conf.d/ /etc/nginx/conf.d/
COPY dist/ /usr/share/nginx/html/
COPY build/ /usr/share/nginx/html/

View File

@@ -1,21 +1,28 @@
# Armandine
Static gallery site served by Nginx. Runs as a repository-based container; image is built and pushed via Woodpecker CI/CD from this repo.
Pre-rendered Svelte gallery site served by Nginx. Build outputs to `build/`; image is built and pushed via Woodpecker CI/CD from this repo.
## Stack
- **Site:** Static HTML/CSS/JS in `src/`, copied into the image.
- **Runtime:** `nginx:alpine` (see `Dockerfile`).
- **Site:** Svelte 5 + SvelteKit (TypeScript), `@sveltejs/adapter-static`. Gallery is rendered at build time from `src/lib/media.ts`; lightbox and theme toggle run from a preloaded client script (`static/assets/js/script.js`).
- **CSS:** PostCSS (nesting + level 2), component-scoped styles; critical CSS inlined at build time with Beasties.
- **Runtime:** `nginx:alpine` (see `Dockerfile`) serves the contents of `build/`.
- **Registry:** Gitea at `git.mifi.dev` → image `git.mifi.dev/mifi-holdings/armandine`.
- **Deploy:** Portainer on Linode; stack uses this image (no volume for site content).
## Local development
```bash
# Install dev dependencies (ESLint, Prettier, Stylelint)
# Install dependencies
pnpm install
# Lint JS and CSS
# Dev server with hot reload (http://localhost:5173)
pnpm dev
# Type-check (Svelte + TypeScript)
pnpm check
# Lint JS/TS and CSS
pnpm lint
# Check formatting (CI uses this)
@@ -24,8 +31,11 @@ pnpm format:check
# Fix formatting
pnpm format
# Preview site locally (serves src/ on http://localhost:3000)
pnpm serve
# Build for production (Vite + critical CSS step)
pnpm build
# Preview the built site (http://localhost:4173)
pnpm preview
```
## Dev container
@@ -34,29 +44,32 @@ Open the repo in a dev container (VS Code/Cursor: **Dev Containers: Reopen in Co
- **Node 22** (matches CI), with `pnpm install` run after create
- **Docker (outside of Docker)** uses the host Docker socket so you can run `pnpm build` and test the image inside the dev container
- **ESLint, Prettier, Stylelint** extensions plus format-on-save and fix-on-save
- **ESLint, Prettier, Stylelint, Svelte** extensions plus format-on-save and fix-on-save
Port **3000** is forwarded for `pnpm serve`; port **80** is forwarded if you run the built Nginx container locally.
Ports:
- **5173** SvelteKit dev server (`pnpm dev`)
- **4173** Preview built site (`pnpm preview`)
- **80** Nginx when running the Docker container locally
## Manual build and push
When you want to build and push the image yourself (e.g. before CI was set up, or for a one-off deploy):
1. Log in to the Gitea container registry:
```bash
docker login git.mifi.dev
```
Use your Gitea username and a token with package permissions.
2. Build the image (tags as `latest`):
2. Build the site and Docker image:
```bash
pnpm build
pnpm docker:build
```
This runs: `docker build -t git.mifi.dev/mifi-holdings/armandine:latest .`
`pnpm build` runs Vite build then Beasties to inline critical CSS into `build/`. The Dockerfile copies `build/` into the image.
3. Push to the registry:
```bash
pnpm push
pnpm docker:push
```
Then on the server, redeploy the stack (e.g. Portainer “Pull and redeploy” or your webhook).
@@ -66,8 +79,8 @@ Three pipelines (see `.woodpecker/`):
| Pipeline | When | What |
|----------|------|------|
| **ci** | Every push to `main`, every PR | Lint (ESLint + Stylelint) and Prettier check |
| **build**| Push/tag/manual on `main` only (after ci) | Build Docker image, push to `git.mifi.dev/mifi-holdings/armandine` |
| **ci** | Every push to `main`, every PR | Lint (ESLint + Stylelint), Prettier check, Svelte/TypeScript check |
| **build**| Push/tag/manual on `main` only (after ci) | Build site to `build/`, build Docker image, push to `git.mifi.dev/mifi-holdings/armandine` |
| **deploy** | After build | Trigger Portainer stack redeploy via webhook |
Order: **ci** → **build** → **deploy**.
@@ -79,32 +92,50 @@ Configure in the repos Woodpecker secrets:
- `gitea_registry_username` Gitea user for registry login
- `gitea_package_token` Gitea token with package read/write
- `portainer_webhook_url` Portainer stack webhook URL for redeploy
- `discord_webhook_url` (optional) Discord notifications for build/deploy status
- Mattermost/Discord webhook secrets (see `.woodpecker/*.yaml` for notifications)
## Server / Portainer
- Stack is defined by `docker-compose.yml` in this repo.
- Compose uses the image from the registry (`git.mifi.dev/mifi-holdings/armandine:latest`); no volume for site content (its inside the image).
- Ensure the server can pull from `git.mifi.dev` (login or registry access). After a push, either let the deploy pipeline trigger the Portainer webhook or manually “Pull and redeploy” the stack.
- Ensure the server can pull from `git.mifi.dev`. After a push, either let the deploy pipeline trigger the Portainer webhook or manually “Pull and redeploy” the stack.
## Project layout
```
├── src/ # Static site (copied into image)
│ ├── index.html
├── src/
│ ├── app.html # SvelteKit HTML template
│ ├── app.css # Global CSS (variables, body, lightbox)
│ ├── app.d.ts # App types
│ ├── routes/
│ │ ├── +layout.svelte
│ │ ├── +layout.ts # prerender = true
│ │ ├── +page.svelte # Main page (header, gallery, lightbox markup)
│ │ └── +page.ts # Loads media data for build-time gallery
│ └── lib/
│ ├── media.ts # Typed gallery media list (used at build time)
│ └── components/
│ ├── GalleryFigure.svelte
│ └── SiteHeader.svelte
├── static/ # Copied as-is to build root
│ ├── robots.txt
│ └── assets/
│ ├── css/
│ ├── js/
│ └── media/
├── Dockerfile # nginx:alpine + COPY src → /usr/share/nginx/html
├── docker-compose.yml # Stack for Portainer (Traefik, healthcheck)
├── package.json # Scripts: build, push, lint, format, serve (pnpm)
├── .devcontainer/ # Dev container (Node 22, Docker, lint/format tools)
│ ├── js/ # script.js (lightbox, theme), ga-init.js
│ ├── media/ # desktop/, tablet/, mobile/, thumbnail/, videos/
│ └── favicon*.png, favicon.ico
├── scripts/
│ └── critical-css.js # Beasties post-step on build/
├── build/ # Output of pnpm build (Vite + Beasties)
├── Dockerfile # nginx:alpine + COPY build/
├── docker-compose.yml
├── package.json # Scripts: dev, build, preview, check, lint, format
├── .devcontainer/
├── .woodpecker/
│ ├── ci.yaml # Lint + format check (PR + main)
│ ├── build.yaml # Build image, push to registry
│ └── deploy.yaml # Portainer webhook
── README.md # This file
│ ├── ci.yaml
│ ├── build.yaml
│ └── deploy.yaml
── README.md
└── AGENTS.md # Guidance for LLM agents
```
## Version

View File

@@ -1,28 +1,26 @@
import prettierConfig from 'eslint-config-prettier/flat'
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
import prettierConfig from 'eslint-config-prettier/flat';
export default [
{
files: ['src/**/*.js'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'script',
globals: {
window: 'readonly',
document: 'readonly',
dataLayer: 'writable'
}
},
rules: {
'no-undef': 'warn',
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'no-var': 'warn',
'prefer-arrow-callback': 'warn',
'prefer-const': 'warn',
'prefer-destructuring': 'warn',
'prefer-rest-params': 'warn',
'prefer-spread': 'warn',
'prefer-template': 'warn',
}
},
prettierConfig
]
{ ignores: ['**/*.d.ts'] },
eslint.configs.recommended,
...tseslint.configs.recommended,
{
files: ['src/**/*.ts', 'src/**/*.js'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
window: 'readonly',
document: 'readonly'
}
},
rules: {
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'prefer-const': 'warn'
}
},
prettierConfig
];

View File

@@ -1,37 +1,51 @@
{
"name": "armandine",
"version": "1.0.0",
"type": "module",
"private": true,
"packageManager": "pnpm@10.29.3+sha512.498e1fb4cca5aa06c1dcf2611e6fafc50972ffe7189998c409e90de74566444298ffe43e6cd2acdc775ba1aa7cc5e092a8b7054c811ba8c5770f84693d33d2dc",
"description": "Armandine gallery static Nginx site",
"scripts": {
"build": "node scripts/build.js",
"docker:build": "docker build --platform linux/amd64 -t git.mifi.dev/mifi-holdings/landing:latest .",
"docker:push": "docker push git.mifi.dev/mifi-holdings/landing:latest",
"format": "prettier --write \"src/**/*.{html,css,js,json}\"",
"format:check": "prettier --check \"src/**/*.{html,css,js,json}\"",
"lint": "pnpm run lint:yaml && pnpm run lint:js && pnpm run lint:css",
"lint:css": "stylelint \"src/**/*.css\"",
"lint:js": "eslint src/",
"lint:yaml": "yamllint .woodpecker/ci.yml .woodpecker/build.yml .woodpecker/deploy.yml docker-compose.yml",
"lint:fix": "pnpm run lint:fix:js && pnpm run lint:fix:css && pnpm run lint:fix:yaml",
"lint:fix:js": "eslint src/ --fix",
"lint:fix:css": "stylelint \"src/**/*.css\" --fix",
"lint:fix:yaml": "yamllint .woodpecker/ci.yml .woodpecker/build.yml .woodpecker/deploy.yml docker-compose.yml --fix",
"preview": "serve src -l 3000",
"preview:prod": "pnpm build && serve dist -l 3000"
},
"devDependencies": {
"clean-css": "^5.3.3",
"beasties": "^0.4.1",
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.4.2",
"serve": "^14.2.4",
"stylelint": "^17.3.0",
"stylelint-config-standard": "^40.0.0",
"terser": "^5.46.0",
"yaml-lint": "^1.7.0"
}
"name": "armandine",
"version": "1.0.0",
"type": "module",
"private": true,
"packageManager": "pnpm@10.29.3+sha512.498e1fb4cca5aa06c1dcf2611e6fafc50972ffe7189998c409e90de74566444298ffe43e6cd2acdc775ba1aa7cc5e092a8b7054c811ba8c5770f84693d33d2dc",
"description": "Armandine gallery pre-rendered Svelte site",
"scripts": {
"dev": "vite dev",
"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",
"format": "prettier --write \"src/**/*.{ts,js,svelte,css,json}\" \"static/**/*.js\"",
"format:check": "prettier --check \"src/**/*.{ts,js,svelte,css,json}\" \"static/**/*.js\"",
"lint": "pnpm run lint:yaml && pnpm run lint:js && pnpm run lint:css",
"lint:css": "stylelint \"src/**/*.css\"",
"lint:js": "eslint src/",
"lint:yaml": "yamllint .woodpecker/ci.yaml .woodpecker/build.yaml .woodpecker/deploy.yaml docker-compose.yml",
"lint:fix": "pnpm run lint:fix:js && pnpm run lint:fix:css && pnpm run lint:fix:yaml",
"lint:fix:js": "eslint src/ --fix",
"lint:fix:css": "stylelint \"src/**/*.css\" --fix",
"lint:fix:yaml": "yamllint .woodpecker/ci.yaml .woodpecker/build.yaml .woodpecker/deploy.yaml docker-compose.yml --fix",
"docker:build": "docker build --platform linux/amd64 -t git.mifi.dev/mifi-holdings/armandine:latest .",
"docker:push": "docker push git.mifi.dev/mifi-holdings/armandine:latest"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"beasties": "^0.4.1",
"@eslint/js": "^10.0.1",
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8",
"typescript-eslint": "^8.0.0",
"postcss": "^8.4.49",
"postcss-load-config": "^6.0.0",
"postcss-nesting": "^14.0.0",
"postcss-preset-env": "^11.1.3",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.2.0",
"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",
"yaml-lint": "^1.7.0"
}
}

2683
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

9
postcss.config.js Normal file
View File

@@ -0,0 +1,9 @@
export default {
plugins: {
'postcss-nesting': {},
'postcss-preset-env': {
stage: 2,
features: { 'nesting-rules': false }
}
}
};

View File

@@ -1,78 +0,0 @@
/**
* Build script: copy src → dist, minify JS/CSS, inline critical CSS (Beasties).
* Run with: pnpm build
*/
import {
rmSync,
mkdirSync,
readFileSync,
writeFileSync,
cpSync,
readdirSync
} from 'fs'
import { join, dirname, extname } from 'path'
import { fileURLToPath } from 'url'
import Beasties from 'beasties'
import { minify as minifyJs } from 'terser'
import CleanCSS from 'clean-css'
const __dirname = dirname(fileURLToPath(import.meta.url))
const root = join(__dirname, '..')
const srcDir = join(root, 'src')
const distDir = join(root, 'dist')
function getFiles(dir, files = []) {
const entries = readdirSync(dir, { withFileTypes: true })
for (const e of entries) {
const full = join(dir, e.name)
if (e.isDirectory()) getFiles(full, files)
else files.push(full)
}
return files
}
async function main() {
// 1. Clean and copy src → dist
rmSync(distDir, { recursive: true, force: true })
mkdirSync(distDir, { recursive: true })
cpSync(srcDir, distDir, { recursive: true })
const distFiles = getFiles(distDir)
// 2. Minify JS
const jsFiles = distFiles.filter((f) => extname(f) === '.js')
for (const f of jsFiles) {
const code = readFileSync(f, 'utf8')
const result = await minifyJs(code, { format: { comments: false } })
if (result.code) writeFileSync(f, result.code)
}
// 3. Minify CSS
const cleanCss = new CleanCSS({ level: 2 })
const cssFiles = distFiles.filter((f) => extname(f) === '.css')
for (const f of cssFiles) {
const code = readFileSync(f, 'utf8')
const result = cleanCss.minify(code)
if (!result.errors.length) writeFileSync(f, result.styles)
}
// 4. Inline critical CSS with Beasties for all HTML files (no browser; works in CI)
const htmlFiles = distFiles.filter((f) => extname(f) === '.html')
const beasties = new Beasties({
path: distDir,
preload: 'default',
logLevel: 'warn'
})
for (const htmlFile of htmlFiles) {
const html = readFileSync(htmlFile, 'utf8')
const inlined = await beasties.process(html)
writeFileSync(htmlFile, inlined)
}
console.log('Build complete: dist/')
}
main().catch((err) => {
console.error(err)
process.exit(1)
})

40
scripts/critical-css.js Normal file
View File

@@ -0,0 +1,40 @@
/**
* Inline critical CSS into built HTML using Beasties.
* Run after vite build; reads/writes build/.
*/
import { readFileSync, writeFileSync, readdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import Beasties from 'beasties';
const __dirname = dirname(fileURLToPath(import.meta.url));
const buildDir = join(__dirname, '..', 'build');
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;
}
const beasties = new Beasties({
path: buildDir,
preload: 'default',
logLevel: 'warn'
});
async function main() {
const htmlFiles = getFiles(buildDir, '.html');
for (const htmlFile of htmlFiles) {
const html = readFileSync(htmlFile, 'utf8');
const inlined = await beasties.process(html);
writeFileSync(htmlFile, inlined);
}
console.log('Critical CSS inlined:', htmlFiles.length, 'file(s)');
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

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

67
src/app.css Normal file
View File

@@ -0,0 +1,67 @@
:root {
--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;
--lightbox-backdrop: rgb(0 0 0 / 90%);
--surface-elevated: #222;
}
}
/* Explicit theme toggle overrides (win over media query when set) */
html {
&[data-theme='dark'] {
--bg: #111;
--fg: #eee;
--accent: #46c;
--lightbox-backdrop: rgb(0 0 0 / 90%);
--surface-elevated: #222;
}
&[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);
}
.lightbox-open {
overflow: hidden;
}
.gallery-grid {
column-count: 1;
column-gap: 1rem;
padding: 1rem;
}
@media (width >= 768px) {
.gallery-grid {
column-count: 2;
}
}
@media (width >= 1024px) {
.gallery-grid {
column-count: 3;
}
}

13
src/app.d.ts vendored Normal file
View File

@@ -0,0 +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 {}
}
}
export {};

10
src/app.html Normal file
View File

@@ -0,0 +1,10 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -1,147 +0,0 @@
:root {
--bg: #fff;
--fg: #222;
--accent: #007acc;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111;
--fg: #eee;
--accent: #46c;
}
}
/* Explicit theme toggle overrides (win over media query when set) */
html.dark {
--bg: #111;
--fg: #eee;
--accent: #46c;
}
html.light {
--bg: #fff;
--fg: #222;
--accent: #007acc;
}
body {
margin: 0;
font-family: sans-serif;
background-color: var(--bg);
color: var(--fg);
}
.lightbox-open {
overflow: hidden;
}
.site-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: var(--bg);
border-bottom: 1px solid #ccc;
}
.emoji-button {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--accent);
}
.gallery-grid {
column-count: 1;
column-gap: 1rem;
padding: 1rem;
}
@media (width >= 768px) {
.gallery-grid {
column-count: 2;
}
}
@media (width >= 1024px) {
.gallery-grid {
column-count: 3;
}
}
.gallery-item {
aspect-ratio: 3/2;
margin: 0 0 1rem;
position: relative;
cursor: pointer;
}
.gallery-item img {
height: auto;
width: 100%;
display: block;
border-radius: 8px;
}
.gallery-item 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;
}
.gallery-item:focus figcaption,
.gallery-item:hover figcaption {
opacity: 1;
}
#lightbox {
position: fixed;
inset: 0;
background: rgb(0 0 0 / 90%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
visibility: hidden;
opacity: 0;
transition: opacity 0.3s;
}
#lightbox[aria-hidden='false'] {
visibility: visible;
opacity: 1;
}
#lb-content img,
#lb-content video {
max-width: 90vw;
max-height: 80vh;
border-radius: 8px;
}
#lb-caption {
color: #fff;
margin-top: 0.5rem;
text-align: center;
max-width: 90vw;
}
#lb-close {
position: absolute;
top: 1rem;
right: 1rem;
background: none;
border: none;
font-size: 2rem;
color: #fff;
cursor: pointer;
}

View File

@@ -1,120 +0,0 @@
// --- theme toggle ---
const toggle = document.getElementById('theme-toggle');
const root = document.documentElement;
const saved = window?.localStorage?.getItem('dark-mode');
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');
} else {
if (sysDark) {
root.classList.add('dark');
root.classList.remove('light');
} else {
root.classList.add('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', !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');
lbCnt.innerHTML = '';
});
// --- build gallery ---
const gallery = document.getElementById('gallery');
const mediaData = JSON.parse(document.getElementById('media-data').textContent);
const createPicture = (item) => {
const pic = document.createElement('picture');
['desktop', 'tablet', 'mobile'].forEach((bp) => {
const src = document.createElement('source');
const widthQuery = bp === 'desktop' ? 1024 : bp === 'tablet' ? 768 : 0;
src.media = `(min-width:${widthQuery}px)`;
if (item.type === 'image') {
src.srcset =
`assets/media/${bp}/${item.name}@1x.webp 1x, ` +
`assets/media/${bp}/${item.name}.webp 2x`;
} else {
// video poster still
src.srcset =
`assets/media/${bp}/${item.name}_still@1x.webp 1x, ` +
`assets/media/${bp}/${item.name}_still.webp 2x`;
}
pic.appendChild(src);
});
// thumbnail fallback (always 300px/2×)
const img = document.createElement('img');
img.src = `assets/media/thumbnail/${item.name}.webp`;
img.alt = item.alt.replace(/[]/g, '');
img.height = item.height || undefined;
img.width = item.width || undefined;
img.loading = item.loading || 'lazy';
img.fetchPriority = item.fetchpriority || undefined;
pic.appendChild(img);
return pic;
};
mediaData.forEach((item) => {
const fig = document.createElement('figure');
fig.className = `gallery-item${item.type === 'video' ? ' video' : ''}`;
fig.tabIndex = 0;
fig.dataset.name = item.name;
fig.dataset.type = item.type;
fig.dataset.caption = item.caption;
fig.appendChild(createPicture(item));
// overlay caption
const cap = document.createElement('figcaption');
cap.textContent = item.caption;
fig.appendChild(cap);
// events
fig.addEventListener('click', () => openLightbox(item));
fig.addEventListener(
'keypress',
(e) => e.key === 'Enter' && openLightbox(item),
);
gallery.appendChild(fig);
});
// --- video toggle ---
const videoTgl = document.getElementById('show_video');
videoTgl.addEventListener('click', () => {
openLightbox(mediaData.find((i) => i.type === 'video'));
});
function openLightbox(item) {
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;
v.loading = item.loading || 'lazy';
lbCnt.appendChild(v);
} else {
lbCnt.appendChild(createPicture(item));
}
lbCap.textContent = item.caption;
body.classList.add('lightbox-open');
lb.setAttribute('aria-hidden', 'false');
}

View File

@@ -1,246 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<!-- Google tag (gtag.js) -->
<script
async
src="https://www.googletagmanager.com/gtag/js?id=G-QZGFK4MDT4"
></script>
<script
defer
src="/assets/js/ga-init.js"
data-ga-id="G-QZGFK4MDT4"
></script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>64 Armandine St #3 Boston, Massachusetts</title>
<meta
name="description"
content="An inviting blend of comfort and curated art—relaxation guaranteed."
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/assets/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/assets/favicon-16x16.png"
/>
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
<link rel="stylesheet" href="/assets/css/style.css" />
</head>
<body>
<header class="site-header">
<h1>64 Armandine St #3 Boston, Massachusetts</h1>
<div class="buttons">
<button
id="show_video"
class="emoji-button"
aria-label="Show video tour"
>
🎥
</button>
<button
id="theme-toggle"
class="emoji-button"
aria-label="Toggle light/dark theme"
>
🌓
</button>
</div>
</header>
<main>
<section id="gallery" class="gallery-grid">
<!-- gallery items are injected here by script.js -->
</section>
</main>
<!-- Lightbox -->
<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>
<!-- Your media manifest: list each file (no extension), type, and caption -->
<script id="media-data" type="application/json">
[
{
"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
}
]
</script>
<script src="assets/js/script.js"></script>
</body>
</html>

View File

@@ -0,0 +1,100 @@
<script lang="ts">
import type { MediaItem } from '$lib/media.js';
interface Props {
index?: number;
item: MediaItem;
showLightbox: (item: MediaItem) => void;
}
let { item, index, showLightbox }: Props = $props();
</script>
<button
class={`gallery-item ${item.type === 'video' ? ' video' : ''}`}
tabindex="0"
data-name={item.name}
data-type={item.type}
data-caption={item.caption}
onclick={() => showLightbox(item)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
showLightbox(item);
}
}}
>
<figure>
<picture>
{#each [{ bp: 'desktop', minWidth: 1024 }, { bp: 'tablet', minWidth: 768 }, { bp: 'mobile', minWidth: 0 }] as breakpoint}
<source
media="(min-width:{breakpoint.minWidth}px)"
srcset={item.type === 'image'
? `/assets/media/${breakpoint.bp}/${item.name}@1x.webp 1x, /assets/media/${breakpoint.bp}/${item.name}.webp 2x`
: `/assets/media/${breakpoint.bp}/${item.name}_still@1x.webp 1x, /assets/media/${breakpoint.bp}/${item.name}_still.webp 2x`}
/>
{/each}
<img
src="/assets/media/thumbnail/{item.name}.webp"
alt={item.alt.replace(/['']/g, '')}
height={item.height ?? undefined}
width={item.width ?? undefined}
loading={index && index > 2
? (item.loading ?? 'lazy')
: undefined}
fetchpriority={item.fetchpriority ?? undefined}
/>
</picture>
<figcaption>{item.caption}</figcaption>
</figure>
</button>
<style>
.gallery-item {
aspect-ratio: 3/2;
background: none;
border: none;
color: inherit;
cursor: pointer;
font: inherit;
margin: 0 0 1rem;
position: relative;
padding: 0;
text-align: left;
width: 100%;
& figure {
margin: 0;
}
& img {
height: auto;
width: 100%;
display: block;
border-radius: 8px;
}
& figcaption {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
padding: 0.5rem;
background: rgb(0 0 0 / 60%);
color: #fff;
font-size: 0.9rem;
opacity: 0;
transition: opacity 0.2s;
}
&:focus-visible {
outline: 2px solid var(--accent);
}
&:focus,
&:hover {
& figcaption {
opacity: 1;
}
}
}
</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

@@ -0,0 +1,47 @@
<script lang="ts">
import { theme } from '$lib/stores/theme.svelte.js';
const toggleTheme = () => {
theme.set(theme.get() === 'light' ? 'dark' : 'light');
};
</script>
<header class="site-header">
<h1>64 Armandine St #3 Boston, Massachusetts</h1>
<div class="buttons">
<button
id="show_video"
class="emoji-button"
aria-label="Show video tour"
>
🎥
</button>
<button
id="theme-toggle"
class="emoji-button"
aria-label="Toggle light/dark theme"
onclick={toggleTheme}
>
🌓
</button>
</div>
</header>
<style>
.site-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: var(--bg);
border-bottom: 1px solid #ccc;
}
.emoji-button {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--accent);
}
</style>

182
src/lib/media.ts Normal file
View File

@@ -0,0 +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';
}
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,
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,
},
];

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

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

2
src/routes/+layout.ts Normal file
View File

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

88
src/routes/+page.svelte Normal file
View File

@@ -0,0 +1,88 @@
<script lang="ts">
import type { MediaItem } from '$lib/media.js';
import Lightbox from '$lib/components/Lightbox.svelte';
import GalleryFigure from '$lib/components/GalleryFigure.svelte';
import SiteHeader from '$lib/components/SiteHeader.svelte';
interface Props {
data: { mediaItems: MediaItem[] };
}
let { data }: Props = $props();
const title = '64 Armandine St #3 Boston, Massachusetts';
const description =
'An inviting blend of comfort and curated art—relaxation guaranteed.';
const canonical = 'https://armandine.mifi.holdings/';
const gaId = 'G-QZGFK4MDT4';
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Place',
name: title,
description,
address: {
'@type': 'PostalAddress',
streetAddress: '64 Armandine St #3',
addressLocality: 'Boston',
addressRegion: 'MA',
addressCountry: 'US',
},
};
let showPicture = $state<MediaItem | null>(null);
const showLightbox = (item: MediaItem) => {
showPicture = item;
};
const onClose = () => {
showPicture = null;
};
</script>
<svelte:head>
<title>{title}</title>
<meta name="description" content={description} />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="canonical" href={canonical} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:type" content="website" />
<meta property="og:url" content={canonical} />
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/assets/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/assets/favicon-16x16.png"
/>
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
<link rel="preload" href="/assets/js/script.js" as="script" />
<script defer src="/assets/js/script.js"></script>
<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>`}
</svelte:head>
<SiteHeader />
<main>
<section id="gallery" class="gallery-grid">
{#each data.mediaItems as item, index (item.name)}
<GalleryFigure {item} {index} {showLightbox} />
{/each}
</section>
</main>
<Lightbox item={showPicture} {onClose} />

5
src/routes/+page.ts Normal file
View File

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

View File

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

View File

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

Before

Width:  |  Height:  |  Size: 353 KiB

After

Width:  |  Height:  |  Size: 353 KiB

View File

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

Before

Width:  |  Height:  |  Size: 896 B

After

Width:  |  Height:  |  Size: 896 B

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,13 @@
const root = document.documentElement;
const saved = window?.localStorage?.getItem('theme');
const sysDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (saved) {
root.setAttribute('data-theme', saved);
} else {
if (sysDark) {
root.setAttribute('data-theme', 'dark');
} else {
root.setAttribute('data-theme', 'light');
}
}

View File

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

Before

Width:  |  Height:  |  Size: 347 KiB

After

Width:  |  Height:  |  Size: 347 KiB

View File

Before

Width:  |  Height:  |  Size: 826 KiB

After

Width:  |  Height:  |  Size: 826 KiB

View File

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 130 KiB

View File

Before

Width:  |  Height:  |  Size: 903 KiB

After

Width:  |  Height:  |  Size: 903 KiB

View File

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 146 KiB

View File

Before

Width:  |  Height:  |  Size: 637 KiB

After

Width:  |  Height:  |  Size: 637 KiB

View File

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

Before

Width:  |  Height:  |  Size: 630 KiB

After

Width:  |  Height:  |  Size: 630 KiB

View File

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

View File

Before

Width:  |  Height:  |  Size: 494 KiB

After

Width:  |  Height:  |  Size: 494 KiB

View File

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 135 KiB

View File

Before

Width:  |  Height:  |  Size: 618 KiB

After

Width:  |  Height:  |  Size: 618 KiB

View File

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 167 KiB

View File

Before

Width:  |  Height:  |  Size: 294 KiB

After

Width:  |  Height:  |  Size: 294 KiB

View File

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 142 KiB

View File

Before

Width:  |  Height:  |  Size: 315 KiB

After

Width:  |  Height:  |  Size: 315 KiB

View File

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

View File

Before

Width:  |  Height:  |  Size: 373 KiB

After

Width:  |  Height:  |  Size: 373 KiB

View File

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

View File

Before

Width:  |  Height:  |  Size: 1005 KiB

After

Width:  |  Height:  |  Size: 1005 KiB

View File

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 188 KiB

View File

Before

Width:  |  Height:  |  Size: 538 KiB

After

Width:  |  Height:  |  Size: 538 KiB

View File

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 113 KiB

View File

Before

Width:  |  Height:  |  Size: 748 KiB

After

Width:  |  Height:  |  Size: 748 KiB

View File

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 144 KiB

View File

Before

Width:  |  Height:  |  Size: 800 KiB

After

Width:  |  Height:  |  Size: 800 KiB

View File

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 129 KiB

View File

Before

Width:  |  Height:  |  Size: 770 KiB

After

Width:  |  Height:  |  Size: 770 KiB

View File

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 115 KiB

View File

Before

Width:  |  Height:  |  Size: 826 KiB

After

Width:  |  Height:  |  Size: 826 KiB

View File

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 126 KiB

View File

Before

Width:  |  Height:  |  Size: 688 KiB

After

Width:  |  Height:  |  Size: 688 KiB

View File

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

View File

Before

Width:  |  Height:  |  Size: 423 KiB

After

Width:  |  Height:  |  Size: 423 KiB

View File

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

View File

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 176 KiB

View File

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 127 KiB

View File

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

View File

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View File

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 137 KiB

View File

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

View File

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View File

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

View File

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

View File

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Some files were not shown because too many files have changed in this diff Show More