diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3a88f48 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +# Keep the build context minimal; only the site assets and nginx config +# referenced by the Dockerfile are needed. +.git +.gitea +.github +.idea +.vscode +*.md +renovate.json +.renovaterc* +Dockerfile +.dockerignore diff --git a/.gitea/workflows/build-and-publish.yml b/.gitea/workflows/build-and-publish.yml new file mode 100644 index 0000000..c58d334 --- /dev/null +++ b/.gitea/workflows/build-and-publish.yml @@ -0,0 +1,53 @@ +name: Build and publish container + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Determine registry host + run: echo "REGISTRY=${GITHUB_SERVER_URL#*://}" >> "$GITHUB_ENV" + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to the Gitea container registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITEA_TOKEN }} + + - name: Extract image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ github.repository }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7525657 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +# Lightweight, non-root nginx serving the static site. +# Runs as user "nginx" and listens on 8080, ready to sit behind an +# external reverse proxy that terminates TLS and forwards requests. +FROM nginxinc/nginx-unprivileged:1.27-alpine-slim + +COPY default.conf /etc/nginx/conf.d/default.conf +COPY --chown=nginx:nginx index.html styles.css script.js messages.js /usr/share/nginx/html/ + +EXPOSE 8080 diff --git a/README.md b/README.md new file mode 100644 index 0000000..6abb542 --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# Why is the DLR shut today? + +A single-page site that displays one randomly chosen message in the centre of +the screen. The message changes on every page load and whenever the +**Check again** button is pressed. + +The site is themed around the Docklands Light Railway colour scheme, with a +toggle between: + +- **Modern colours** — the current DLR turquoise/teal branding. +- **Original colours** — the 1987 DLR red-and-blue livery. + +The chosen theme is remembered between visits via `localStorage`. + +## Adding messages + +Edit `messages.js` and fill the `MESSAGES` array with your own reasons — one +string per entry. Entries are inserted as plain text. Until you add some, the +page shows a fallback prompt. + +## Running + +It is a static site with no build step. Open `index.html` in a browser, or +serve the directory with any static file server, for example: + +```sh +python3 -m http.server +``` + +## Container + +The site is packaged as a container based on `nginxinc/nginx-unprivileged`. It +runs as a non-root user and listens on port **8080**, serving the static files +and exposing a `/healthz` endpoint. It is designed to sit behind an external +reverse proxy that terminates TLS and routes by host. + +Build and run locally: + +```sh +docker build -t dlr . +docker run --rm -p 8080:8080 dlr +# then browse http://localhost:8080 +``` + +## CI + +`.gitea/workflows/build-and-publish.yml` builds the container with Gitea Actions +and publishes it to this Gitea instance's container registry on pushes to `main` +and on `v*` tags. Pull requests build the image but do not push. Authentication +uses the automatically provided `GITEA_TOKEN`; the registry host is derived from +the Gitea server URL. + +The published image is `//`, tagged by branch, semver +(for `v*` tags), commit SHA, and `latest` on the default branch. + +## Dependency updates + +`renovate.json` configures Renovate to keep dependencies current: + +- the Dockerfile base image, +- the actions used in the Gitea workflow, +- versioned front-end dependencies referenced in HTML. + +There are currently no external front-end dependencies. When one is added via a +CDN, Renovate will track it if it is either annotated with a comment, e.g. + +```html + + +``` + +or referenced through a versioned jsDelivr / unpkg npm URL, which is detected +automatically. + +## Files + +| File | Purpose | +| ------------------------------------- | ------------------------------------------------ | +| `index.html` | Page structure. | +| `styles.css` | Both colour schemes, selected via `data-theme`. | +| `messages.js` | The list of messages (fill this in). | +| `script.js` | Random message selection and the theme toggle. | +| `Dockerfile` / `default.conf` | Container image and nginx static-serving config. | +| `.gitea/workflows/` | Gitea Actions build-and-publish pipeline. | +| `renovate.json` | Renovate dependency-update configuration. | diff --git a/default.conf b/default.conf new file mode 100644 index 0000000..449930a --- /dev/null +++ b/default.conf @@ -0,0 +1,42 @@ +# Static file serving for "Why is the DLR shut today?". +# Intended to run behind an external reverse proxy (e.g. NGINX) which +# handles TLS, host routing and any X-Forwarded-* headers. + +server { + listen 8080; + listen [::]:8080; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_vary on; + gzip_types text/css application/javascript; + + location / { + try_files $uri $uri/ =404; + } + + # HTML should always be revalidated so deploys are picked up promptly. + location = /index.html { + add_header Cache-Control "no-cache"; + } + + # The message list is edited frequently; do not cache it. + location = /messages.js { + add_header Cache-Control "no-cache"; + } + + # Other static assets may be cached for a short period. + location ~* \.(?:css|js)$ { + add_header Cache-Control "public, max-age=3600"; + } + + # Health endpoint for the proxy / orchestrator. + location = /healthz { + access_log off; + default_type text/plain; + return 200 "ok\n"; + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..b299cb1 --- /dev/null +++ b/index.html @@ -0,0 +1,31 @@ + + + + + + Why is the DLR shut today? + + + +
+
+ Colour scheme + + +
+
+ +
+

Why is the DLR shut today?

+

+ +
+ + + + + diff --git a/messages.js b/messages.js new file mode 100644 index 0000000..d3dcf15 --- /dev/null +++ b/messages.js @@ -0,0 +1,18 @@ +/* Messages for "Why is the DLR shut today?" + * + * Fill this array with your own reasons. One string per entry. + * On every page load (and every "Check again" click) one entry is + * picked at random and shown in the centre of the screen. + * + * Keep them short — roughly a sentence — so they fit the large display + * text. HTML is NOT rendered; entries are inserted as plain text. + * + * Replace the placeholders below with your own content. + */ + +const MESSAGES = [ + "PLACEHOLDER: write your first reason here", + "PLACEHOLDER: write another reason here", + // Add as many entries as you like, one per line: + // "Your reason here", +]; diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..f0af024 --- /dev/null +++ b/renovate.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + ":dependencyDashboard", + ":semanticCommits" + ], + "labels": ["renovate"], + "github-actions": { + "fileMatch": ["^\\.gitea/workflows/[^/]+\\.ya?ml$"] + }, + "packageRules": [ + { + "description": "Group nginx base image updates", + "matchManagers": ["dockerfile"], + "groupName": "docker base image" + } + ], + "customManagers": [ + { + "customType": "regex", + "description": "Update HTML dependencies annotated with a renovate comment, e.g. before the versioned URL", + "fileMatch": ["\\.html$"], + "matchStrings": [ + "datasource=(?\\S+) depName=(?\\S+)( versioning=(?\\S+))?[\\s\\S]*?(?v?\\d+\\.\\d+\\.\\d+[\\w.-]*)" + ] + }, + { + "customType": "regex", + "description": "Auto-detect versioned jsDelivr / unpkg npm assets in HTML", + "fileMatch": ["\\.html$"], + "matchStrings": [ + "https://cdn\\.jsdelivr\\.net/npm/(?@?[^@/]+(?:/[^@/]+)?)@(?\\d[^/\"']+)", + "https://unpkg\\.com/(?@?[^@/]+(?:/[^@/]+)?)@(?\\d[^/\"']+)" + ], + "datasourceTemplate": "npm" + } + ] +} diff --git a/script.js b/script.js new file mode 100644 index 0000000..55e67fe --- /dev/null +++ b/script.js @@ -0,0 +1,59 @@ +/* Why is the DLR shut today? + * Picks a random message on load and on demand, and handles the + * colour-scheme toggle. Messages live in messages.js (MESSAGES). */ + +(function () { + "use strict"; + + const FALLBACK = "Add some reasons in messages.js"; + const messageEl = document.getElementById("message"); + const refreshButton = document.getElementById("refresh"); + const themeButtons = document.querySelectorAll("[data-set-theme]"); + const THEME_KEY = "dlr-theme"; + + function pickMessage() { + if (!Array.isArray(MESSAGES) || MESSAGES.length === 0) { + return FALLBACK; + } + const index = Math.floor(Math.random() * MESSAGES.length); + return MESSAGES[index]; + } + + function showMessage() { + messageEl.textContent = pickMessage(); + } + + function applyTheme(theme) { + document.body.setAttribute("data-theme", theme); + themeButtons.forEach(function (button) { + const isActive = button.dataset.setTheme === theme; + button.setAttribute("aria-pressed", String(isActive)); + }); + try { + localStorage.setItem(THEME_KEY, theme); + } catch (err) { + /* localStorage unavailable; theme simply won't persist. */ + } + } + + function initTheme() { + let saved = null; + try { + saved = localStorage.getItem(THEME_KEY); + } catch (err) { + /* ignore */ + } + applyTheme(saved === "original" ? "original" : "modern"); + } + + themeButtons.forEach(function (button) { + button.addEventListener("click", function () { + applyTheme(button.dataset.setTheme); + }); + }); + + refreshButton.addEventListener("click", showMessage); + + initTheme(); + showMessage(); +})(); diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..36d33aa --- /dev/null +++ b/styles.css @@ -0,0 +1,141 @@ +/* Why is the DLR shut today? + Two colour schemes selected via the [data-theme] attribute on : + - modern: current DLR turquoise/teal branding + - original: 1987 DLR red-and-blue livery */ + +:root { + --font-stack: "Helvetica Neue", Helvetica, Arial, sans-serif; +} + +/* Modern DLR — turquoise/teal */ +[data-theme="modern"] { + --bg: #00afaa; + --bg-accent: #007e7a; + --surface: #ffffff; + --text: #ffffff; + --message: #ffffff; + --button-bg: #ffffff; + --button-text: #007e7a; + --button-active-bg: #00302e; + --button-active-text: #ffffff; +} + +/* Original DLR — 1987 red and blue */ +[data-theme="original"] { + --bg: #002b5c; + --bg-accent: #c8102e; + --surface: #f5f0e1; + --text: #f5f0e1; + --message: #f5f0e1; + --button-bg: #c8102e; + --button-text: #f5f0e1; + --button-active-bg: #f5f0e1; + --button-active-text: #002b5c; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + display: flex; + flex-direction: column; + font-family: var(--font-stack); + color: var(--text); + background: linear-gradient(135deg, var(--bg) 0%, var(--bg-accent) 100%); + transition: background 0.4s ease, color 0.4s ease; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + +.controls { + display: flex; + justify-content: flex-end; + padding: 1.5rem; +} + +.theme-toggle { + display: flex; + gap: 0.5rem; + border: none; + margin: 0; + padding: 0; +} + +.theme-button { + font-family: inherit; + font-size: 0.9rem; + font-weight: 600; + padding: 0.5rem 1rem; + border: 2px solid var(--button-bg); + border-radius: 999px; + background: transparent; + color: var(--text); + cursor: pointer; + transition: background 0.2s ease, color 0.2s ease; +} + +.theme-button[aria-pressed="true"] { + background: var(--button-active-bg); + color: var(--button-active-text); + border-color: var(--button-active-bg); +} + +.stage { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 2rem; + gap: 2rem; +} + +.title { + font-size: clamp(1.5rem, 4vw, 2.5rem); + font-weight: 700; + margin: 0; + letter-spacing: 0.02em; +} + +.message { + font-size: clamp(1.75rem, 6vw, 4rem); + font-weight: 800; + line-height: 1.2; + margin: 0; + max-width: 22ch; + color: var(--message); +} + +.refresh-button { + font-family: inherit; + font-size: 1rem; + font-weight: 600; + padding: 0.75rem 1.75rem; + border: 2px solid var(--button-bg); + border-radius: 999px; + background: var(--button-bg); + color: var(--button-text); + cursor: pointer; + transition: transform 0.1s ease, opacity 0.2s ease; +} + +.refresh-button:hover { + opacity: 0.9; +} + +.refresh-button:active { + transform: scale(0.97); +}