devcontainer, proper CI/CD, etc etc

This commit is contained in:
2026-02-10 20:58:16 -03:00
parent bf2ea80830
commit 561b4575e0
18 changed files with 2193 additions and 204 deletions

View File

@@ -0,0 +1,40 @@
{
"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@9.15.0 --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"
}

35
.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
# Dependencies
node_modules
.pnpm-store
.npm
# Lockfiles from other package managers (we use pnpm)
package-lock.json
yarn.lock
bun.lockb
# Local env and secrets
.env
.env.*
!.env.example
# Logs and debug
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
# OS and IDE
.DS_Store
Thumbs.db
.idea
*.swp
*.swo
*~
# Build output (if added later)
dist
build
.next
out

1
.prettierignore Normal file
View File

@@ -0,0 +1 @@
node_modules

6
.prettierrc Normal file
View File

@@ -0,0 +1,6 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5"
}

1
.stylelintignore Normal file
View File

@@ -0,0 +1 @@
node_modules

4
.stylelintrc.json Normal file
View File

@@ -0,0 +1,4 @@
{
"extends": ["stylelint-config-recommended"],
"ignoreFiles": ["node_modules/**"]
}

129
.woodpecker/build.yaml Normal file
View File

@@ -0,0 +1,129 @@
# Deploy: build image, push to registry, trigger Portainer stack redeploy.
# Runs on push/tag/manual to main only, after ci workflow succeeds.
when:
- branch: main
event: [push, tag, manual]
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "production"'
depends_on:
- ci
steps:
- name: Docker image build
image: docker:latest
environment:
REGISTRY_REPO: git.mifi.dev/mifi-holdings/armandine
DOCKER_API_VERSION: '1.43'
DOCKER_BUILDKIT: '1'
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- set -e
- echo "=== Building Docker image (BuildKit) ==="
- 'echo "Commit SHA: ${CI_COMMIT_SHA:0:8}"'
- 'echo "Registry repo: $REGISTRY_REPO"'
- |
build() {
docker build \
--progress=plain \
--tag $REGISTRY_REPO:${CI_COMMIT_SHA} \
--tag $REGISTRY_REPO:latest \
--label "git.commit=${CI_COMMIT_SHA}" \
--label "git.branch=${CI_COMMIT_BRANCH}" \
.
}
for attempt in 1 2 3; do
echo "Build attempt $attempt/3"
if build; then
echo "✓ Docker image built successfully"
exit 0
fi
echo "Build attempt $attempt failed, retrying in 30s..."
sleep 30
done
echo "All build attempts failed"
exit 1
- name: Send Docker Image Build Status Notification (success)
image: curlimages/curl
environment:
DISCORD_WEBHOOK_URL:
from_secret: discord_webhook_url
commands:
- |
BODY=$(printf '{"username":"WoodpeckerBot","content":"[%s - Build #%s] Docker image build success 🎉"}' "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" "$DISCORD_WEBHOOK_URL"
depends_on:
- Docker image build
when:
- status: [success]
- name: Send Docker Image Build Status Notification (failure)
image: curlimages/curl
environment:
DISCORD_WEBHOOK_URL:
from_secret: discord_webhook_url
commands:
- |
BODY=$(printf '{"username":"WoodpeckerBot","content":"[%s - Build #%s] Docker image build failure 💩"}' "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" "$DISCORD_WEBHOOK_URL"
depends_on:
- Docker image build
when:
- status: [failure]
- name: Push to registry
image: docker:latest
environment:
DOCKER_API_VERSION: '1.43'
REGISTRY_URL: git.mifi.dev
REGISTRY_REPO: git.mifi.dev/mifi-holdings/armandine
REGISTRY_USERNAME:
from_secret: gitea_registry_username
REGISTRY_PASSWORD:
from_secret: gitea_package_token
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- set -e
- echo "=== Pushing to registry ==="
- 'echo "Registry: $REGISTRY_URL"'
- 'echo "Repository: $REGISTRY_REPO"'
- |
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_URL" \
-u "$REGISTRY_USERNAME" \
--password-stdin
- docker push $REGISTRY_REPO:${CI_COMMIT_SHA}
- docker push $REGISTRY_REPO:latest
- echo "✓ Images pushed successfully"
depends_on:
- Docker image build
- name: Send Push to Registry Status Notification (success)
image: curlimages/curl
environment:
DISCORD_WEBHOOK_URL:
from_secret: discord_webhook_url
commands:
- |
BODY=$(printf '{"username":"WoodpeckerBot","content":"[%s - Build #%s] Push to registry success 🎉"}' "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" "$DISCORD_WEBHOOK_URL"
depends_on:
- Push to registry
when:
- status: [success]
- name: Send Push to Registry Status Notification (failure)
image: curlimages/curl
environment:
DISCORD_WEBHOOK_URL:
from_secret: discord_webhook_url
commands:
- |
BODY=$(printf '{"username":"WoodpeckerBot","content":"[%s - Build #%s] Push to registry failure 💩"}' "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" "$DISCORD_WEBHOOK_URL"
depends_on:
- Push to registry
when:
- status: [failure]

33
.woodpecker/ci.yaml Normal file
View File

@@ -0,0 +1,33 @@
# CI: lint and format check. Runs on every PR and every push to main.
when:
- event: pull_request
- branch: main
event: push
steps:
- name: install
image: node:22-alpine
commands:
- corepack enable
- corepack prepare pnpm@9.15.0 --activate
- pnpm install --frozen-lockfile
- name: lint
image: node:22-alpine
commands:
- corepack enable
- corepack prepare pnpm@9.15.0 --activate
- pnpm install --frozen-lockfile
- pnpm lint
depends_on:
- install
- name: format check
image: node:22-alpine
commands:
- corepack enable
- corepack prepare pnpm@9.15.0 --activate
- pnpm install --frozen-lockfile
- pnpm format:check
depends_on:
- install

59
.woodpecker/deploy.yaml Normal file
View File

@@ -0,0 +1,59 @@
# Deploy: build image, push to registry, trigger Portainer stack redeploy.
# Runs on push/tag/manual to main only, after ci workflow succeeds.
when:
- branch: main
event: [push, tag, manual]
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "production"'
depends_on:
- build
steps:
- name: Trigger Portainer stack redeploy
image: curlimages/curl:latest
environment:
PORTAINER_WEBHOOK_URL:
from_secret: portainer_webhook_url
commands:
- set -e
- echo "=== Triggering Portainer stack redeploy ==="
- |
resp=$(curl -s -w "\n%{http_code}" -X POST "$PORTAINER_WEBHOOK_URL")
body=$(echo "$resp" | head -n -1)
code=$(echo "$resp" | tail -n 1)
if [ "$code" != "200" ] && [ "$code" != "204" ]; then
echo "Webhook failed (HTTP $code): $body"
exit 1
fi
echo "✓ Portainer redeploy triggered (HTTP $code)"
depends_on:
- Push to registry
- name: Send Deploy Status Notification (success)
image: curlimages/curl
environment:
DISCORD_WEBHOOK_URL:
from_secret: discord_webhook_url
commands:
- |
BODY=$(printf '{"username":"WoodpeckerBot","content":"[%s - Build #%s] Production Deploy success 🎉"}' "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" "$DISCORD_WEBHOOK_URL"
depends_on:
- Trigger Portainer stack redeploy
when:
- status: [success]
- name: Send Deploy Status Notification (failure)
image: curlimages/curl
environment:
DISCORD_WEBHOOK_URL:
from_secret: discord_webhook_url
commands:
- |
BODY=$(printf '{"username":"WoodpeckerBot","content":"[%s - Build #%s] Production Deploy failure 💩"}' "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" "$DISCORD_WEBHOOK_URL"
depends_on:
- Trigger Portainer stack redeploy
when:
- status: [failure]

10
Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
# Armandine gallery static site served by Nginx
FROM nginx:alpine
# Copy static site into default Nginx docroot
COPY src/ /usr/share/nginx/html/
# Optional: custom nginx config could be COPY'd here
# COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

112
README.md
View File

@@ -0,0 +1,112 @@
# 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.
## Stack
- **Site:** Static HTML/CSS/JS in `src/`, copied into the image.
- **Runtime:** `nginx:alpine` (see `Dockerfile`).
- **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)
pnpm install
# Lint JS and CSS
pnpm lint
# Check formatting (CI uses this)
pnpm format:check
# Fix formatting
pnpm format
# Preview site locally (serves src/ on http://localhost:3000)
pnpm serve
```
## Dev container
Open the repo in a dev container (VS Code/Cursor: **Dev Containers: Reopen in Container**) for a consistent environment:
- **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
Port **3000** is forwarded for `pnpm serve`; port **80** is forwarded if you run the built Nginx 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`):
```bash
pnpm build
```
This runs: `docker build -t git.mifi.dev/mifi-holdings/armandine:latest .`
3. Push to the registry:
```bash
pnpm push
```
Then on the server, redeploy the stack (e.g. Portainer “Pull and redeploy” or your webhook).
## Woodpecker CI/CD
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` |
| **deploy** | After build | Trigger Portainer stack redeploy via webhook |
Order: **ci** → **build** → **deploy**.
### Secrets (Woodpecker)
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
## 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.
## Project layout
```
├── src/ # Static site (copied into image)
│ ├── index.html
│ └── 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)
├── .woodpecker/
│ ├── ci.yaml # Lint + format check (PR + main)
│ ├── build.yaml # Build image, push to registry
│ └── deploy.yaml # Portainer webhook
└── README.md # This file
```
## Version
`package.json` version: **1.0.0** (bump when you want to track releases).

24
docker-compose.yml Normal file
View File

@@ -0,0 +1,24 @@
services:
armandine-gallery:
image: git.mifi.dev/mifi-holdings/armandine:latest
container_name: mifi-holdings-armandine
networks:
- marina-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=marina-net"
- "traefik.http.routers.armandine-gallery.rule=Host(`armandine.mifi.holdings`)"
- "traefik.http.routers.armandine-gallery.entrypoints=websecure"
- "traefik.http.routers.armandine-gallery.middlewares=security-prison@file"
- "traefik.http.routers.armandine-gallery.tls=true"
- "traefik.http.routers.armandine-gallery.tls.certresolver=letsencrypt"
- "traefik.http.services.armandine-gallery.loadbalancer.server.port=80"
healthcheck:
test: ["CMD-SHELL", "wget --spider -q http://localhost/ || exit 1"]
interval: 20s
timeout: 3s
retries: 3
networks:
marina-net:
external: true

26
eslint.config.js Normal file
View File

@@ -0,0 +1,26 @@
import js from '@eslint/js';
import prettier from 'eslint-config-prettier';
export default [
{
files: ['src/**/*.js'],
...js.configs.recommended,
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'script',
globals: {
document: 'readonly',
window: 'readonly',
localStorage: 'readonly',
console: 'readonly',
fetch: 'readonly',
Image: 'readonly',
CustomEvent: 'readonly',
},
},
rules: {
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
},
},
prettier,
];

26
package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "armandine",
"version": "1.0.0",
"type": "module",
"private": true,
"packageManager": "pnpm@9.15.0",
"description": "Armandine gallery static Nginx site",
"scripts": {
"build": "docker build -t git.mifi.dev/mifi-holdings/armandine:latest .",
"push": "docker push git.mifi.dev/mifi-holdings/armandine:latest",
"lint": "pnpm lint:js && pnpm lint:css",
"lint:js": "eslint src",
"lint:css": "stylelint \"src/**/*.css\"",
"format": "prettier --write \"src/**/*.{html,css,js,json}\"",
"format:check": "prettier --check \"src/**/*.{html,css,js,json}\"",
"serve": "pnpm exec serve src -p 3000"
},
"devDependencies": {
"@eslint/js": "^9.15.0",
"eslint": "^9.15.0",
"eslint-config-prettier": "^9.1.0",
"prettier": "^3.3.3",
"stylelint": "^16.10.0",
"stylelint-config-recommended": "^14.0.0"
}
}

1452
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -46,10 +46,14 @@ body {
padding: 1rem;
}
@media (min-width: 768px) {
.gallery-grid { column-count: 2; }
.gallery-grid {
column-count: 2;
}
}
@media (min-width: 1024px) {
.gallery-grid { column-count: 3; }
.gallery-grid {
column-count: 3;
}
}
.gallery-item {
margin: 0 0 1rem;
@@ -67,7 +71,7 @@ body {
left: 0;
width: 100%;
padding: 0.5rem;
background: rgba(0,0,0,0.6);
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 0.9rem;
opacity: 0;
@@ -79,8 +83,11 @@ body {
}
#lightbox {
position: fixed;
top:0; left:0; right:0; bottom:0;
background: rgba(0,0,0,0.9);
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
display: flex;
flex-direction: column;
align-items: center;
@@ -89,7 +96,7 @@ body {
opacity: 0;
transition: opacity 0.3s;
}
#lightbox[aria-hidden="false"] {
#lightbox[aria-hidden='false'] {
visibility: visible;
opacity: 1;
}

View File

@@ -1,10 +1,10 @@
// --- theme toggle (unchanged) ---
const toggle = document.getElementById('theme-toggle');
const root = document.documentElement;
const saved = localStorage.getItem('dark-mode');
const sysDark= window.matchMedia('(prefers-color-scheme: dark)').matches;
if (saved==='true' || (saved===null && sysDark)) root.classList.add('dark');
toggle.addEventListener('click', ()=>{
const root = document.documentElement;
const saved = localStorage.getItem('dark-mode');
const sysDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (saved === 'true' || (saved === null && sysDark)) root.classList.add('dark');
toggle.addEventListener('click', () => {
const isDark = root.classList.toggle('dark');
localStorage.setItem('dark-mode', isDark);
});
@@ -12,33 +12,32 @@ toggle.addEventListener('click', ()=>{
const body = document.body;
// --- 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='';
});
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 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=>{
['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 =
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 =
src.srcset =
`assets/media/${bp}/${item.name}_still@1x.webp 1x, ` +
`assets/media/${bp}/${item.name}_still.webp 2x`;
}
@@ -47,18 +46,18 @@ const createPicture = (item) => {
// 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.src = `assets/media/thumbnail/${item.name}.webp`;
img.alt = item.alt.replace(/[]/g, '');
pic.appendChild(img);
return pic;
};
mediaData.forEach(item=>{
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.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));
@@ -68,30 +67,33 @@ mediaData.forEach(item=>{
fig.appendChild(cap);
// events
fig.addEventListener('click', ()=> openLightbox(item));
fig.addEventListener('keypress', e=> e.key==='Enter' && openLightbox(item));
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'));
videoTgl.addEventListener('click', () => {
openLightbox(mediaData.find((i) => i.type === 'video'));
});
function openLightbox(item){
lbCnt.innerHTML = '';
if(item.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.src = `assets/media/videos/${item.name}.mp4`;
v.controls = true;
v.autoplay = true;
lbCnt.appendChild(v);
} else {
lbCnt.appendChild(createPicture(item));
}
lbCap.textContent = item.caption;
lbCap.textContent = item.caption;
body.classList.add('lightbox-open');
lb.setAttribute('aria-hidden','false');
lb.setAttribute('aria-hidden', 'false');
}

View File

@@ -1,162 +1,184 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>64 Armandine St #3 Boston, Massachusetts</title>
<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>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>64 Armandine St #3 Boston, Massachusetts</title>
<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>
</header>
<main>
<section id="gallery" class="gallery-grid">
<!-- gallery items are injected here by script.js -->
</section>
</main>
<!-- 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."
},
{
"type": "image",
"name": "living_room_2",
"caption": "Relaxation elevated—your stylish living space awaits.",
"alt": "Spacious living area featuring elegant furniture and tasteful decor."
},
{
"type": "image",
"name": "kitchen",
"caption": "The culinary stage is set—snacking encouraged, style required.",
"alt": "Modern kitchen showcasing sleek appliances and contemporary design."
},
{
"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."
},
{
"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."
},
{
"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."
},
{
"type": "image",
"name": "guest_bath",
"caption": "Your personal spa experience—right down the hall.",
"alt": "Sophisticated guest bathroom with modern fixtures and clean lines."
},
{
"type": "image",
"name": "onsuite_1",
"caption": "Luxury meets practicality—your private ensuite awaits.",
"alt": "Private ensuite bathroom featuring contemporary design and premium finishes."
},
{
"type": "image",
"name": "onsuite_2",
"caption": "Everyday luxury, right at home—your ensuite oasis.",
"alt": "Elegant ensuite with sleek fixtures and stylish decor."
},
{
"type": "image",
"name": "laundry",
"caption": "Laundry day reimagined—functional never looked so good.",
"alt": "Modern laundry room with washer, dryer, and organized storage."
},
{
"type": "image",
"name": "coat_closet",
"caption": "Organized and chic—your entryway's best friend.",
"alt": "Convenient coat closet with tidy storage solutions."
},
{
"type": "image",
"name": "deck_1",
"caption": "Outdoor comfort, just steps away—morning coffee optional.",
"alt": "Sunny deck with cozy seating and pleasant outdoor views."
},
{
"type": "image",
"name": "deck_2",
"caption": "Your fresh-air escape—ideal for relaxing evenings.",
"alt": "Comfortable deck area perfect for unwinding or entertaining."
},
{
"type": "image",
"name": "exterior",
"caption": "Curb appeal perfected—your new favorite place starts here.",
"alt": "Attractive home exterior with inviting architecture."
},
{
"type": "image",
"name": "backyard_parking",
"caption": "Convenience meets privacy—your personal backyard parking spot.",
"alt": "Private backyard parking area offering secure convenience."
},
{
"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."
},
{
"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."
},
{
"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."
},
{
"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."
},
{
"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."
}
]
</script>
<!-- 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."
},
{
"type": "image",
"name": "living_room_2",
"caption": "Relaxation elevated—your stylish living space awaits.",
"alt": "Spacious living area featuring elegant furniture and tasteful decor."
},
{
"type": "image",
"name": "kitchen",
"caption": "The culinary stage is set—snacking encouraged, style required.",
"alt": "Modern kitchen showcasing sleek appliances and contemporary design."
},
{
"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."
},
{
"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."
},
{
"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."
},
{
"type": "image",
"name": "guest_bath",
"caption": "Your personal spa experience—right down the hall.",
"alt": "Sophisticated guest bathroom with modern fixtures and clean lines."
},
{
"type": "image",
"name": "onsuite_1",
"caption": "Luxury meets practicality—your private ensuite awaits.",
"alt": "Private ensuite bathroom featuring contemporary design and premium finishes."
},
{
"type": "image",
"name": "onsuite_2",
"caption": "Everyday luxury, right at home—your ensuite oasis.",
"alt": "Elegant ensuite with sleek fixtures and stylish decor."
},
{
"type": "image",
"name": "laundry",
"caption": "Laundry day reimagined—functional never looked so good.",
"alt": "Modern laundry room with washer, dryer, and organized storage."
},
{
"type": "image",
"name": "coat_closet",
"caption": "Organized and chic—your entryway's best friend.",
"alt": "Convenient coat closet with tidy storage solutions."
},
{
"type": "image",
"name": "deck_1",
"caption": "Outdoor comfort, just steps away—morning coffee optional.",
"alt": "Sunny deck with cozy seating and pleasant outdoor views."
},
{
"type": "image",
"name": "deck_2",
"caption": "Your fresh-air escape—ideal for relaxing evenings.",
"alt": "Comfortable deck area perfect for unwinding or entertaining."
},
{
"type": "image",
"name": "exterior",
"caption": "Curb appeal perfected—your new favorite place starts here.",
"alt": "Attractive home exterior with inviting architecture."
},
{
"type": "image",
"name": "backyard_parking",
"caption": "Convenience meets privacy—your personal backyard parking spot.",
"alt": "Private backyard parking area offering secure convenience."
},
{
"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."
},
{
"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."
},
{
"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."
},
{
"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."
},
{
"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."
}
]
</script>
<script src="assets/js/script.js"></script>
</body>
<script src="assets/js/script.js"></script>
</body>
</html>