Compare commits
13 Commits
e712e73902
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
a44155d87d
|
|||
|
e41c15c47e
|
|||
|
cbe900a8e4
|
|||
|
1033101c6f
|
|||
|
645b5ecd35
|
|||
|
f2382391ab
|
|||
|
0f2dba0ce7
|
|||
|
7870d3b3bd
|
|||
|
af70682fa8
|
|||
| 6734dfa51e | |||
|
9f74726236
|
|||
|
4f863e5686
|
|||
|
99cb89d1e8
|
@@ -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
@@ -28,7 +28,10 @@ Thumbs.db
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Build output (if added later)
|
||||
# SvelteKit
|
||||
.svelte-kit
|
||||
|
||||
# Build output
|
||||
dist
|
||||
build
|
||||
.next
|
||||
|
||||
22
.prettierrc
@@ -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" } }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
@@ -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/`. **CSR is disabled** (`csr = false` in `+layout.ts`), so no Svelte runtime or app JS is loaded in the browser. The gallery and shell (header, empty lightbox dialog) are **rendered at build time** from Svelte components and `src/lib/media.ts`. Lightbox, theme toggle, and “show video” are implemented in **`static/assets/js/script.js`** only; they bind 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 only in `static/assets/js/script.js` (no client-side Svelte). They rely on the pre-rendered DOM (e.g. `.gallery-item[data-name]`, `dialog.lightbox`, `#show_video`, `#theme-toggle`). The Svelte `Lightbox` component is still used at build time to output the empty `<dialog class="lightbox">`; script.js creates the inner structure (header, .lb-content, #lb-caption) on first open. Keep all runtime behavior in script.js.
|
||||
- **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`).
|
||||
@@ -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/
|
||||
|
||||
93
README.md
@@ -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 repo’s 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 (it’s 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
|
||||
|
||||
@@ -10,7 +10,7 @@ services:
|
||||
- "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-supermax-with-analytics@file"
|
||||
- "traefik.http.routers.armandine-gallery.middlewares=gzip@file,security-supermax-with-analytics@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"
|
||||
|
||||
@@ -1,28 +1,43 @@
|
||||
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'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['static/**/*.js'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
window: 'readonly',
|
||||
document: 'readonly',
|
||||
localStorage: 'readonly'
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }],
|
||||
'prefer-const': 'warn'
|
||||
}
|
||||
},
|
||||
prettierConfig
|
||||
];
|
||||
|
||||
85
package.json
@@ -1,37 +1,52 @@
|
||||
{
|
||||
"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": "serve build",
|
||||
"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": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@sveltejs/adapter-static": "^3.0.1",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"beasties": "^0.4.1",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"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",
|
||||
"serve": "^14.2.5",
|
||||
"stylelint": "^17.3.0",
|
||||
"stylelint-config-standard": "^40.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"terser": "^5.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"vite": "^7.3.1",
|
||||
"yaml-lint": "^1.7.0"
|
||||
}
|
||||
}
|
||||
|
||||
2293
pnpm-lock.yaml
generated
9
postcss.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'postcss-nesting': {},
|
||||
'postcss-preset-env': {
|
||||
stage: 2,
|
||||
features: { 'nesting-rules': false }
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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)
|
||||
})
|
||||
73
scripts/critical-css.js
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 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 { log, error } from 'node:console';
|
||||
import process from 'node:process';
|
||||
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',
|
||||
inlineThreshold: 50 * 1024,
|
||||
minimumExternalSize: 50 * 1024
|
||||
});
|
||||
|
||||
/**
|
||||
* Remove SvelteKit wrapper div and Svelte SSR fragment comments from HTML.
|
||||
* With csr = false these add noise and the inline style is unnecessary.
|
||||
*/
|
||||
function cleanHtml(html) {
|
||||
// Remove extra SvelteKit body attribute: data-sveltekit-preload-data="hover"
|
||||
html = html.replace(/<body\s+data-sveltekit-preload-data\s*=\s*["']hover["']\s*>/i, '<body>');
|
||||
// Remove SvelteKit's root wrapper: <div style="display: contents"> ... </div>
|
||||
html = html.replace(/<div\s+style\s*=\s*["']display:\s*contents["']\s*>/i, '');
|
||||
const bodyEnd = html.indexOf('</body>');
|
||||
if (bodyEnd !== -1) {
|
||||
const beforeBody = html.slice(0, bodyEnd);
|
||||
const lastDiv = beforeBody.lastIndexOf('</div>');
|
||||
if (lastDiv !== -1) {
|
||||
html = beforeBody.slice(0, lastDiv) + beforeBody.slice(lastDiv + 6) + html.slice(bodyEnd);
|
||||
}
|
||||
}
|
||||
// Remove Svelte fragment/hydration comments (unused when csr = false)
|
||||
html = html
|
||||
.replace(/<!--\[-->/g, '')
|
||||
.replace(/<!--\]-->/g, '')
|
||||
.replace(/<!--\[!-->/g, '')
|
||||
.replace(/<!--\]!-->/g, '')
|
||||
.replace(/<!---->/g, '');
|
||||
return html;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const htmlFiles = getFiles(buildDir, '.html');
|
||||
for (const htmlFile of htmlFiles) {
|
||||
let html = readFileSync(htmlFile, 'utf8');
|
||||
html = await beasties.process(html);
|
||||
html = cleanHtml(html);
|
||||
writeFileSync(htmlFile, html);
|
||||
}
|
||||
log('Critical CSS inlined:', htmlFiles.length, 'file(s)');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
79
scripts/externalize-bootstrap.js
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Move SvelteKit's inline bootstrap script to an external file for CSP (no unsafe-inline).
|
||||
* Run after vite build; reads/writes build/.
|
||||
* Finds <script>...</script> containing __sveltekit_, minifies it, writes to _app/immutable/entry/bootstrap.js,
|
||||
* and replaces the inline script with <script src="...">.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, readdirSync, mkdirSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { minify } from 'terser';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const buildDir = join(__dirname, '..', 'build');
|
||||
const entryDir = join(buildDir, '_app', 'immutable', 'entry');
|
||||
const bootstrapPath = join(entryDir, 'bootstrap.js');
|
||||
const scriptSrc = './_app/immutable/entry/bootstrap.js';
|
||||
|
||||
function getFiles(dir, ext, files = []) {
|
||||
for (const name of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, name.name);
|
||||
if (name.isDirectory()) getFiles(full, ext, files);
|
||||
else if (name.name.endsWith(ext)) files.push(full);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
// Find first <script>...</script> that contains __sveltekit_
|
||||
function findInlineBootstrap(html) {
|
||||
const scriptOpen = html.indexOf('<script>');
|
||||
if (scriptOpen === -1) return null;
|
||||
const scriptClose = html.indexOf('</script>', scriptOpen);
|
||||
if (scriptClose === -1) return null;
|
||||
const content = html.slice(scriptOpen + '<script>'.length, scriptClose);
|
||||
if (!content.includes('__sveltekit_')) return null;
|
||||
return { content: content.trim(), start: scriptOpen, end: scriptClose + '</script>'.length };
|
||||
}
|
||||
|
||||
const SCRIPT_TAG = `<script src="${scriptSrc}"></script>`;
|
||||
|
||||
async function main() {
|
||||
const htmlFiles = getFiles(buildDir, '.html');
|
||||
let bootstrapWritten = false;
|
||||
let count = 0;
|
||||
for (const htmlFile of htmlFiles) {
|
||||
let html = readFileSync(htmlFile, 'utf8');
|
||||
const found = findInlineBootstrap(html);
|
||||
if (!found) continue;
|
||||
|
||||
if (!bootstrapWritten) {
|
||||
// Imports relative to script location when in _app/immutable/entry/
|
||||
let scriptContent = found.content.replace(
|
||||
/import\("\.\/_app\/immutable\/entry\/([^"]+)"\)/g,
|
||||
'import("./$1")'
|
||||
);
|
||||
const result = await minify(scriptContent, {
|
||||
format: { comments: false },
|
||||
compress: { passes: 1 }
|
||||
});
|
||||
if (result.code) scriptContent = result.code;
|
||||
mkdirSync(entryDir, { recursive: true });
|
||||
writeFileSync(bootstrapPath, scriptContent, 'utf8');
|
||||
bootstrapWritten = true;
|
||||
}
|
||||
|
||||
html = html.slice(0, found.start) + SCRIPT_TAG + html.slice(found.end);
|
||||
writeFileSync(htmlFile, html, 'utf8');
|
||||
count++;
|
||||
}
|
||||
if (count > 0) {
|
||||
console.log('Bootstrap script externalized (minified):', scriptSrc, `(${count} HTML file(s))`);
|
||||
} else if (htmlFiles.length > 0) {
|
||||
console.log('No SvelteKit inline script found in HTML (bootstrap already external?)');
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
127
src/app.css
Normal file
@@ -0,0 +1,127 @@
|
||||
: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 (structure may be created by script.js; ensures styles apply to injected content) */
|
||||
.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;
|
||||
}
|
||||
|
||||
.lightbox::backdrop {
|
||||
background: var(--lightbox-backdrop);
|
||||
}
|
||||
|
||||
.lightbox[open] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.lightbox header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0.25rem 0.25rem 0 0;
|
||||
}
|
||||
|
||||
.lightbox .lb-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 2rem;
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lightbox .lb-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.lightbox .lb-content img,
|
||||
.lightbox .lb-content video {
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.lightbox .lb-caption {
|
||||
color: var(--fg);
|
||||
margin-top: 0.5rem;
|
||||
text-align: center;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.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
@@ -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
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 347 KiB |
|
Before Width: | Height: | Size: 826 KiB |
|
Before Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 903 KiB |
|
Before Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 637 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 630 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 494 KiB |
|
Before Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 618 KiB |
|
Before Width: | Height: | Size: 167 KiB |
|
Before Width: | Height: | Size: 294 KiB |
|
Before Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 315 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 373 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 1005 KiB |
|
Before Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 538 KiB |
|
Before Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 748 KiB |
|
Before Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 800 KiB |
|
Before Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 770 KiB |
|
Before Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 826 KiB |
|
Before Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 688 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 423 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 388 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 152 KiB |