Compare commits
239 Commits
7f455a08d5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74924ea06f | ||
|
|
4653a2caae | ||
|
|
7a60d33610 | ||
|
|
0b4a68b81b | ||
|
|
65885dbb3a | ||
|
|
8dbeca7570 | ||
|
|
b8bb1944dd | ||
|
|
75af3e1f0e | ||
|
|
d12edea8de | ||
|
|
2ee6575539 | ||
|
|
08516fda14 | ||
|
|
262d943443 | ||
|
|
4659edc658 | ||
|
|
563d4b63cc | ||
|
|
6066f486de | ||
|
|
a6502b168d | ||
|
|
15b626ee0d | ||
|
|
a0c78929b3 | ||
|
|
fe8acac707 | ||
|
|
dc1ca5c00e | ||
|
|
2f125f32ae | ||
|
|
c06af38d80 | ||
|
|
5b59f5745c | ||
|
|
7afc554f5d | ||
|
|
db90e9f452 | ||
|
|
8dd05d4d03 | ||
|
|
67ab0d2ddb | ||
|
|
9d758f5254 | ||
|
|
4df4cd1055 | ||
|
|
2fb88916d5 | ||
|
|
e82fdf1230 | ||
|
|
f4b388d70b | ||
|
|
90c8321ba3 | ||
|
|
4c9294490e | ||
|
|
0d56f36a26 | ||
|
|
275a955a5f | ||
|
|
af79b439ce | ||
|
|
17e02977d6 | ||
|
|
9b259ac173 | ||
|
|
2bb816b2ae | ||
|
|
76a708e8fc | ||
| 1883097397 | |||
|
|
0db2151f92 | ||
|
|
d0610a5907 | ||
|
|
53df5b46a3 | ||
|
|
b7f08ba46d | ||
|
|
832eb1a913 | ||
|
|
a980d6393e | ||
|
|
1b8c74a204 | ||
|
|
cd62a01ec5 | ||
|
|
d63dc6d2cf | ||
|
|
2213fe8a0c | ||
|
|
4965341e1e | ||
|
|
5cccf8a822 | ||
|
|
d08965126f | ||
|
|
e6a8f7b1e9 | ||
|
|
e436fe532c | ||
|
|
9de58a438a | ||
|
|
3dfbf6fd5e | ||
|
|
75648ff77b | ||
|
|
14d35af78e | ||
|
|
2488f07808 | ||
|
|
87926dc7cd | ||
|
|
0392a0fb05 | ||
|
|
c435a7eb78 | ||
|
|
314ca414b3 | ||
|
|
41d5f8820e | ||
|
|
a45015fae8 | ||
|
|
cc958b74af | ||
|
|
d271f03e4d | ||
|
|
cb33ea3c25 | ||
| 4619213e22 | |||
| 7c3d79354e | |||
| d20236832b | |||
| c182ad18e5 | |||
| 608bfe904e | |||
| 269ee61321 | |||
| c9cf182138 | |||
| b84f5d2691 | |||
| 1b796ce91e | |||
| 9eb6e9ccbb | |||
| ea8a64505b | |||
| 43351539bf | |||
| 966acb8e15 | |||
| fd20eea287 | |||
| 0874b544e0 | |||
| 3215d19915 | |||
| a6feebd664 | |||
| 3446f1f6ff | |||
| d8927c17ba | |||
| 8feaef21f1 | |||
| ce05a3b059 | |||
| 9270f1e6ad | |||
| d69912ec0b | |||
| 6c8afc2be9 | |||
| b9aff29cfd | |||
| bb60827782 | |||
| dba6a3d733 | |||
| 1278027484 | |||
| 92093b9351 | |||
| ad8cd407e2 | |||
| 669eafadd3 | |||
| c865db47da | |||
| 8328d43650 | |||
| 0d9b3cfbd2 | |||
| b9cb71a310 | |||
| 8f8a5dddf9 | |||
| fa1f9d010a | |||
| cb24fa6bd6 | |||
| 791a4a4d27 | |||
| dacc1b9e90 | |||
| cfe2943548 | |||
| 9bcc4507d1 | |||
| d9d2454de4 | |||
| 2b3dcb45ee | |||
| 4f6d206d4e | |||
| 7b3a60f937 | |||
| 6c478207d3 | |||
| ba12affd72 | |||
| b38b0d3270 | |||
| 1566b2c595 | |||
| f267933239 | |||
| 527bc66b05 | |||
| 52a0160198 | |||
| 5dc0b93416 | |||
| 771a73029e | |||
| 0ef179385d | |||
| 909cde2465 | |||
| 2e3efb201b | |||
| 4c7be9373a | |||
| c67501e446 | |||
| 3531636805 | |||
| 83f18a26f0 | |||
| 4519cfe804 | |||
| fc3962ee44 | |||
| be254d661d | |||
| 083ade12eb | |||
| a2bc77805c | |||
| 6f29244850 | |||
| 36ae6062c2 | |||
| 3f8b7c8342 | |||
| 063350b4bc | |||
| 901ee02929 | |||
| 90af31d02a | |||
| d002554036 | |||
| e806c865e5 | |||
| 473bc19d25 | |||
| e0f0e3a0ee | |||
| 67424af48b | |||
| 63bd4d5fd4 | |||
| 110f39303d | |||
| 2b5a23b566 | |||
| eea34a1c68 | |||
| bd368b5ccd | |||
| e2e4f1467f | |||
| 39ee0639b9 | |||
| 14d931431e | |||
| e7d386b2b2 | |||
| e22dfc5a12 | |||
| e45e05ea40 | |||
| 3cd053988c | |||
| 9623935ed0 | |||
| 63f260866e | |||
| ca82878edf | |||
| fb3a474738 | |||
| dababb86fc | |||
| 52a764664e | |||
| ae7505d651 | |||
| a76b559a0e | |||
| 70b428fce7 | |||
| 1789fd7625 | |||
| bebed1f788 | |||
| 66848674e9 | |||
| 138193ce13 | |||
| a18debbf21 | |||
| a504eb9179 | |||
| aea803793a | |||
| 7645a34619 | |||
| 3bfc40b460 | |||
| ee98d815d1 | |||
| a92d2eed57 | |||
| 1667225694 | |||
| 40dbbe3f12 | |||
| f68bd5281b | |||
| f511163bee | |||
| f25d203105 | |||
| a68e262730 | |||
| dbc897370c | |||
| b8b3871650 | |||
| a82053f976 | |||
| fe9d55d638 | |||
| 90724c22ff | |||
| 4533c5129f | |||
| 7ffca5e39b | |||
| 97f497855f | |||
| 6af4cb1d23 | |||
| 8262a076de | |||
| 2df30b9c87 | |||
| 6322e54ba1 | |||
| 3cb44b880c | |||
| c65df7d95a | |||
| 3d87e3d1e8 | |||
| 10c1ffbb19 | |||
| c6d21e0530 | |||
| 4f0b042f09 | |||
| 3c1f76e65c | |||
| dc244c1e98 | |||
| 900ef114a3 | |||
| 2fd5a098b1 | |||
| d33e1771af | |||
| d3a668f353 | |||
| 72cd3e89ad | |||
| 82813556f2 | |||
| cc0b739e66 | |||
| 11fbf2f331 | |||
| fa992de25a | |||
| 687126b974 | |||
| ee15d02a91 | |||
| fcf6e0b10e | |||
| d2266ef1e0 | |||
| b04154cd10 | |||
| 2ae4d73649 | |||
| 86b6e9baed | |||
| 0d96b3e81f | |||
| 2e110ffcf5 | |||
| 5ad20b29f5 | |||
| 2c2a393285 | |||
| 7072189dde | |||
| bb299b2f98 | |||
| 1f5bd9e089 | |||
| 172a1f85c3 | |||
| 529acd4fcc | |||
| 7fbe850756 | |||
| 73ba1b3eb5 | |||
| 4d1ab5c244 | |||
| 8441402f49 | |||
| 0ea03a61cf | |||
| 623727e71c | |||
| 20f770ad59 |
60
.gitea/workflows/build.yml
Executable file
@@ -0,0 +1,60 @@
|
|||||||
|
name: Build Org Website
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- assets/content/hidden-details.json
|
||||||
|
- assets/scripts/hidden-details.js
|
||||||
|
|
||||||
|
schedule:
|
||||||
|
- cron: "0 0 * * *"
|
||||||
|
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: org-web-build
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: org-web
|
||||||
|
|
||||||
|
env:
|
||||||
|
SITE_OUTPUT_DIR: /home/zaine/master-folder/org-platform/org_web/output
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Commit hidden details files on scheduled/manual run
|
||||||
|
if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
|
||||||
|
run: |
|
||||||
|
cd /home/zaine/master-folder/org-platform/org_web
|
||||||
|
|
||||||
|
git switch main
|
||||||
|
|
||||||
|
git config user.name "gitea-actions"
|
||||||
|
git config user.email "gitea-actions@localhost"
|
||||||
|
|
||||||
|
git add assets/content/hidden-details.json assets/scripts/hidden-details.js
|
||||||
|
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "No hidden details changes to commit."
|
||||||
|
else
|
||||||
|
git commit -m "Update hidden details [skip actions]"
|
||||||
|
git push origin main
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build site
|
||||||
|
run: |
|
||||||
|
cd /home/zaine/master-folder/org-platform/org_web
|
||||||
|
mkdir -p /home/zaine/logs
|
||||||
|
SITE_CLEAN=1 make 2>&1 | tee /home/zaine/logs/org-web.log
|
||||||
|
|
||||||
|
- name: Monitor build and notify Discord
|
||||||
|
if: always()
|
||||||
|
env:
|
||||||
|
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||||
|
API_KEY: ${{ secrets.API_KEY }}
|
||||||
|
run: |
|
||||||
|
cd /home/zaine/master-folder/org-platform/org_web
|
||||||
|
/snap/bin/pwsh -NoProfile -File ./build-logs/gitea-build-monitor.ps1
|
||||||
7
.gitignore
vendored
@@ -1,4 +1,9 @@
|
|||||||
.venv/
|
.venv/
|
||||||
|
node_modules/
|
||||||
|
assets/games/two-rivers/images/characters/*-source.png
|
||||||
output/
|
output/
|
||||||
backup/
|
.org-timestamps/
|
||||||
|
backups/
|
||||||
|
__pycache__/
|
||||||
*~
|
*~
|
||||||
|
.syncthing*.tmp
|
||||||
|
|||||||
5
.projectile
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
-.venv/
|
||||||
|
-output/
|
||||||
|
-backup/
|
||||||
|
-*~
|
||||||
|
-.packages/
|
||||||
77
Makefile
@@ -1,19 +1,36 @@
|
|||||||
.PHONY: all clean norm help
|
.PHONY: all clean norm test test-elisp test-js author author-install author-start author-stop author-stop-legacy author-restart author-status author-logs author-health author-diagnostics help
|
||||||
|
|
||||||
VENV := .venv
|
VENV := .venv
|
||||||
PY := $(VENV)/bin/python
|
PY := $(VENV)/bin/python
|
||||||
PIP := $(VENV)/bin/pip
|
PIP := $(VENV)/bin/pip
|
||||||
|
OUTPUT_DIR ?= $(if $(SITE_OUTPUT_DIR),$(SITE_OUTPUT_DIR),output)
|
||||||
|
PERM_DIR := /home/zaine/master-folder/projects/scripts/bash-scripts
|
||||||
|
|
||||||
all: fix-permissions clean-output build search clean-venv
|
AUTHOR_SERVICE_DIR := /home/zaine/master-folder/org-platform/authoring-service
|
||||||
|
|
||||||
|
all: set-perms clean-output build search clean-venv
|
||||||
|
|
||||||
build:
|
build:
|
||||||
@echo "Building project..."
|
@echo "Building project..."
|
||||||
emacs -Q --script build-site.el
|
emacs -Q --script build-site.el
|
||||||
|
|
||||||
|
set-perms:
|
||||||
|
@echo "Setting permissions for output directory..."
|
||||||
|
cd $(PERM_DIR) && sudo ./master-perms.sh
|
||||||
|
|
||||||
search: $(VENV)
|
search: $(VENV)
|
||||||
@echo "Generating search index"
|
@echo "Generating search index"
|
||||||
$(PY) search-index-json.py
|
$(PY) search-index-json.py
|
||||||
|
|
||||||
|
test:
|
||||||
|
$(MAKE) -C "$(AUTHOR_SERVICE_DIR)" test
|
||||||
|
|
||||||
|
test-elisp:
|
||||||
|
emacs -Q --batch -l lisp/tests/build-site-tests.el -f ert-run-tests-batch-and-exit
|
||||||
|
|
||||||
|
test-js:
|
||||||
|
node --test tests/*.test.js
|
||||||
|
|
||||||
$(VENV):
|
$(VENV):
|
||||||
@echo "Generating virtual environment"
|
@echo "Generating virtual environment"
|
||||||
python3 -m venv $(VENV)
|
python3 -m venv $(VENV)
|
||||||
@@ -21,7 +38,7 @@ $(VENV):
|
|||||||
|
|
||||||
clean-output:
|
clean-output:
|
||||||
@echo "Cleaning output directory..."
|
@echo "Cleaning output directory..."
|
||||||
rm -rf output/
|
rm -rf "$(OUTPUT_DIR)"
|
||||||
|
|
||||||
clean-venv:
|
clean-venv:
|
||||||
@echo "Cleaning virtual environment..."
|
@echo "Cleaning virtual environment..."
|
||||||
@@ -31,18 +48,52 @@ norm:
|
|||||||
@echo "sorting out the backups..."
|
@echo "sorting out the backups..."
|
||||||
find . -path ./backups -prune -o -type f -name '*~' -exec mv {} backups/ \;
|
find . -path ./backups -prune -o -type f -name '*~' -exec mv {} backups/ \;
|
||||||
|
|
||||||
fix-permissions:
|
author: author-start
|
||||||
echo "shakkal123" | sudo -S chown -R zaine:zaine .
|
|
||||||
|
|
||||||
|
author-install:
|
||||||
|
$(MAKE) -C "$(AUTHOR_SERVICE_DIR)" author-install
|
||||||
|
|
||||||
|
author-start:
|
||||||
|
$(MAKE) -C "$(AUTHOR_SERVICE_DIR)" author-start
|
||||||
|
|
||||||
|
author-stop:
|
||||||
|
$(MAKE) -C "$(AUTHOR_SERVICE_DIR)" author-stop
|
||||||
|
|
||||||
|
author-stop-legacy:
|
||||||
|
$(MAKE) -C "$(AUTHOR_SERVICE_DIR)" author-stop-legacy
|
||||||
|
|
||||||
|
author-restart:
|
||||||
|
$(MAKE) -C "$(AUTHOR_SERVICE_DIR)" author-restart
|
||||||
|
|
||||||
|
author-health:
|
||||||
|
$(MAKE) -C "$(AUTHOR_SERVICE_DIR)" author-health
|
||||||
|
|
||||||
|
author-status:
|
||||||
|
$(MAKE) -C "$(AUTHOR_SERVICE_DIR)" author-status
|
||||||
|
|
||||||
|
author-diagnostics:
|
||||||
|
$(MAKE) -C "$(AUTHOR_SERVICE_DIR)" author-diagnostics
|
||||||
|
|
||||||
|
author-logs:
|
||||||
|
$(MAKE) -C "$(AUTHOR_SERVICE_DIR)" author-logs
|
||||||
|
|
||||||
# Show help message
|
|
||||||
help:
|
help:
|
||||||
@echo "Available targets:"
|
@echo "Available targets:"
|
||||||
@echo " make - Full rebuild"
|
@echo " make - Full rebuild"
|
||||||
@echo " make build - Rebuild the output directory"
|
@echo " make build - Rebuild the output directory"
|
||||||
@echo " make clean-output - Remove output directory"
|
@echo " make set-perms - Set permissions for output directory"
|
||||||
@echo " make clean-venv - Remove venv directory"
|
@echo " make clean-output - Remove output directory"
|
||||||
@echo " make norm - Move all files that end with ~ to the backup folder"
|
@echo " make clean-venv - Remove venv directory"
|
||||||
@echo " make search - creates the search index"
|
@echo " make norm - Move backup files"
|
||||||
@echo " make help - Show this help message"
|
@echo " make test - Run authoring server tests"
|
||||||
|
@echo " make test-elisp - Run Emacs Lisp build tests"
|
||||||
|
@echo " make test-js - Run browser-model JavaScript tests"
|
||||||
|
@echo " make author - Install and start the authoring UI service"
|
||||||
|
@echo " make author-install - Install and enable the authoring UI service"
|
||||||
|
@echo " make author-stop - Stop the authoring UI service"
|
||||||
|
@echo " make author-restart - Restart the authoring UI service"
|
||||||
|
@echo " make author-status - Show authoring UI service status and page count"
|
||||||
|
@echo " make author-diagnostics - Show authoring UI runtime diagnostics"
|
||||||
|
@echo " make author-logs - Show recent authoring UI service logs"
|
||||||
|
@echo " make search - Create the search index"
|
||||||
|
@echo " make help - Show this help message"
|
||||||
|
|||||||
406
README.md
@@ -1,5 +1,405 @@
|
|||||||
# Introduction
|
# zxh / org-web
|
||||||
|
|
||||||
This repository contains code for the website https://zainezq.com, which serves as a central familial hub for me.
|
This repository is the source and publishing pipeline for [zainezq.com](https://zainezq.com), a personal and family website built primarily from Emacs Org-mode files. It contains the written content, the Emacs Lisp build system, browser assets, generated indexes, playful interactive pages, and the Gitea Actions workflow that publishes the site.
|
||||||
|
|
||||||
The site is written using Emacs and Org-mode, and exported using the `org-publish` functionality. See the `build-site.el` script for details on how the site is built and published.
|
This README is the central technical guide for the repository: start here for architecture, authoring, builds, frontend work, testing, and deployment.
|
||||||
|
|
||||||
|
## System overview
|
||||||
|
|
||||||
|
The application has three boundaries:
|
||||||
|
|
||||||
|
1. **Site source (this repository):** Org/Markdown content, Emacs Lisp publishing code, CSS, JavaScript, images, and build tooling.
|
||||||
|
2. **Static output (**`output/`**):** generated HTML, copied assets, and `search-index.json`. This is the deployable website, not the source of truth.
|
||||||
|
3. **Runtime services (outside this repository):** the authoring service and the same-origin APIs used by interactive pages. The static site remains readable without them, but the relevant dynamic features will not work.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Author["Author or authoring service"] --> Source["Org and Markdown source"]
|
||||||
|
Source --> Emacs["Emacs org-publish pipeline"]
|
||||||
|
Assets["CSS, JavaScript, images, fonts"] --> Emacs
|
||||||
|
Emacs --> HTML["output/: HTML and assets"]
|
||||||
|
HTML --> Search["Python search indexer"]
|
||||||
|
Search --> Deploy["Static site at zainezq.com"]
|
||||||
|
Deploy <-->|"same-origin /api requests"| APIs["Runtime APIs outside this repo"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Repository map
|
||||||
|
|
||||||
|
| Path | Purpose | Source or generated? |
|
||||||
|
|----------------------------|------------------------------------------------------------|----------------------------------------------------------------|
|
||||||
|
| `index.org` | Home page source | Source |
|
||||||
|
| `blogs/` | Dated personal writing, grouped by year/month | Source, except generated list pages |
|
||||||
|
| `posts/` | Longer-lived posts; `posts/career/` has its own index | Source, except generated list pages |
|
||||||
|
| `home/` | Utility and dashboard-style pages | Source, except `categories.org` |
|
||||||
|
| `play/` | Interactive and experimental pages | Source |
|
||||||
|
| `rl/` | Server-synced Resource Loader SPA | Source |
|
||||||
|
| `lima/` | Org or Markdown pages with special attachment handling | Source, except `lima-list.org` |
|
||||||
|
| `assets/` | Browser code, styles, fonts, images, and content manifests | Mostly source; some manifests/scripts are generated externally |
|
||||||
|
| `lisp/` | Modular Emacs Lisp publishing system | Source |
|
||||||
|
| `lisp/tests/` | ERT tests for the publishing logic | Source |
|
||||||
|
| `tags/` | Per-tag Org pages created during a build | Generated |
|
||||||
|
| `recently-updated.org` | Recent-content list created during a build | Generated |
|
||||||
|
| `sitemap.org` and `*-list.org` | Sitemap/list sources created by `org-publish` | Generated |
|
||||||
|
| `output/` | Complete publish output | Generated |
|
||||||
|
| `.org-timestamps/` | Incremental publish cache | Generated |
|
||||||
|
| `.packages/` | Repository-local Emacs package directory | Generated/cache |
|
||||||
|
| `build-logs/` | Gitea build monitoring script, cache, and logs | Mixed; script is source, logs/cache are runtime data |
|
||||||
|
| `docs/` | Historical/design notes for individual features | Supplemental; this README is the central engineering guide |
|
||||||
|
|
||||||
|
## Build architecture
|
||||||
|
|
||||||
|
`build-site.el` is the entry point. It establishes absolute source/output roots, bootstraps `htmlize`, configures Org, loads the modules in `lisp/`, defines the publishing projects, and runs the build.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Entry["build-site.el"] --> Core["build-core.el: packages, Org, logging"]
|
||||||
|
Core --> Modules["Load build modules"]
|
||||||
|
Modules --> Projects["build-projects.el: org-publish projects"]
|
||||||
|
Projects --> Prepare["Validate output and timestamp cache"]
|
||||||
|
Prepare --> Inputs["Generate tag pages and comment manifest"]
|
||||||
|
Inputs --> Publish["org-publish-all"]
|
||||||
|
Publish --> OrgHTML["Org/Markdown to HTML"]
|
||||||
|
Publish --> AssetCopy["Copy assets"]
|
||||||
|
OrgHTML --> Sync["Final asset sync"]
|
||||||
|
AssetCopy --> Sync
|
||||||
|
Sync --> Summary["Build summary"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build modules
|
||||||
|
|
||||||
|
| Module | Responsibility |
|
||||||
|
|---------------------|-------------------------------------------------------------------------------------------------------|
|
||||||
|
| `build-core.el` | Logging, timing, local package bootstrap, and base Org configuration |
|
||||||
|
| `build-assets.el` | Shared CSS/JS `<head>` manifest, cache-busted asset URLs, and asset synchronization |
|
||||||
|
| `build-html.el` | Custom HTML backend, page shell, Mermaid export, macros, body classes, tags, and comments |
|
||||||
|
| `build-projects.el` | `org-publish-project-alist` for the main site, posts, blogs, career, tags, Lima, categories, and assets |
|
||||||
|
| `build-runner.el` | Cache integrity, generated inputs, publishing, and build metrics |
|
||||||
|
| `build-lima.el` | Lima Org/Markdown conversion and neighboring attachment copying |
|
||||||
|
| `build-comments.el` | Comment opt-in detection, comment HTML injection, and comment-page manifest generation |
|
||||||
|
| `sitemaps.el` | Main Mermaid sitemap and specialized blog/post/career/category lists |
|
||||||
|
| `tags.el` | Tag discovery, generated tag pages, and tag markup in exported HTML |
|
||||||
|
| `recently-updated.el` | Recent page list generation using `#+DATE` with mtime fallback |
|
||||||
|
| `sidenotes.el` | Sidenote suppression rules for special page layouts |
|
||||||
|
|
||||||
|
The main `org-main` project recursively publishes every Org file, while narrower projects provide specialized sitemap behavior and options. That overlap is intentional: Org's timestamp cache prevents unnecessary repeated work in normal incremental builds.
|
||||||
|
|
||||||
|
### Generated inputs and outputs
|
||||||
|
|
||||||
|
Before publishing, the build writes:
|
||||||
|
|
||||||
|
- `tags/*.org` from `#+FILETAGS`/`#+TAGS` in posts and blogs.
|
||||||
|
- `assets/content/comment-pages.json` from pages with `#+COMMENTS: t`.
|
||||||
|
- `recently-updated.org` from the 26 most recent eligible Org pages.
|
||||||
|
- sitemap/list Org files through `org-publish`.
|
||||||
|
|
||||||
|
After publishing, `search-index-json.py` walks generated HTML, strips script/style markup with Beautiful Soup, and writes `output/search-index.json`. The browser builds a Lunr index from that JSON at page load.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Metadata["Org metadata"] --> Tags["tags/*.org"]
|
||||||
|
Metadata --> Comments["comment-pages.json"]
|
||||||
|
Metadata --> Recent["recently-updated.org"]
|
||||||
|
Tags --> Publish["HTML publish"]
|
||||||
|
Comments --> Publish
|
||||||
|
Recent --> Publish
|
||||||
|
Publish --> Indexer["search-index-json.py"]
|
||||||
|
Indexer --> SearchJSON["output/search-index.json"]
|
||||||
|
SearchJSON --> Lunr["Browser Lunr index"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
For a full local build, install:
|
||||||
|
|
||||||
|
- GNU Emacs with Org mode (Org is bundled with normal Emacs distributions).
|
||||||
|
- Python 3 with `venv` support.
|
||||||
|
- `pandoc`, required when publishing Markdown files under `lima/`.
|
||||||
|
- Network access on the first build if `htmlize` is not already cached in `.packages/`.
|
||||||
|
|
||||||
|
Python packages are listed in `requirements.txt` and installed into `.venv` by Make. The default `make` workflow also invokes a machine-specific permission script with `sudo`; use the direct build commands below on machines that do not have that script.
|
||||||
|
|
||||||
|
## Build and development commands
|
||||||
|
|
||||||
|
### Portable local build
|
||||||
|
|
||||||
|
```sh
|
||||||
|
emacs -Q --script build-site.el
|
||||||
|
python3 -m venv .venv
|
||||||
|
.venv/bin/pip install -r requirements.txt
|
||||||
|
.venv/bin/python search-index-json.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The generated site is in `output/`. Serve it over HTTP so fetch-based features work correctly, for example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m http.server 8000 --directory output
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `http://127.0.0.1:8000`. Dynamic `/api/...` features require the external runtime services or an equivalent local proxy.
|
||||||
|
|
||||||
|
### Make targets
|
||||||
|
|
||||||
|
| Command | Effect |
|
||||||
|
|-------------------|-----------------------------------------------------------------------------------------|
|
||||||
|
| `make build` | Run the Emacs publishing pipeline |
|
||||||
|
| `make search` | Create/update `output/search-index.json`, creating `.venv` when needed |
|
||||||
|
| `make` | Set machine-specific permissions, remove `output/`, build, index search, and remove `.venv` |
|
||||||
|
| `make clean-output` | Delete the configured output directory |
|
||||||
|
| `make test-elisp` | Run the Emacs Lisp ERT suite |
|
||||||
|
| `make test` | Run tests in the sibling authoring-service repository |
|
||||||
|
| `make norm` | Move Emacs backup files into `backups/` |
|
||||||
|
| `make help` | List supported targets |
|
||||||
|
|
||||||
|
### Build environment variables
|
||||||
|
|
||||||
|
| Variable | Meaning |
|
||||||
|
|-----------------------|--------------------------------------------------------------------------|
|
||||||
|
| `SITE_OUTPUT_DIR=/path` | Publish somewhere other than `output/`; the Python indexer respects it too |
|
||||||
|
| `SITE_FORCE=1` | Republish every file instead of using Org timestamps |
|
||||||
|
| `SITE_DRY=1` | Generate/prepare inputs but skip `org-publish-all` |
|
||||||
|
| `SITE_CLEAN=1` | Delete output and `.org-timestamps/`, then force a full publish |
|
||||||
|
|
||||||
|
The build checks that assets and an entry page exist in the output. If the output is missing or incomplete while timestamps remain, it clears the timestamp cache and forces a full publish to avoid a falsely successful incremental build.
|
||||||
|
|
||||||
|
## Authoring content
|
||||||
|
|
||||||
|
### Org pages
|
||||||
|
|
||||||
|
A typical page is:
|
||||||
|
|
||||||
|
```org
|
||||||
|
#+TITLE: Page title
|
||||||
|
#+DATE: <2026-07-12 Sun 12:00>
|
||||||
|
#+FILETAGS: :topic:notes:
|
||||||
|
#+OPTIONS: num:nil toc:t
|
||||||
|
#+COMMENTS: t
|
||||||
|
#+SLUG: stable-page-slug
|
||||||
|
|
||||||
|
* First heading
|
||||||
|
|
||||||
|
Content goes here.
|
||||||
|
```
|
||||||
|
|
||||||
|
The important metadata is:
|
||||||
|
|
||||||
|
- `#+TITLE`: displayed title and sitemap/search label.
|
||||||
|
- `#+DATE`: canonical publication time and ordering source. Recently updated falls back to filesystem modification time when absent or invalid.
|
||||||
|
- `#+FILETAGS`: colon-delimited tags used for category/tag pages and rendered under the page title.
|
||||||
|
- `#+OPTIONS`: normal Org export controls such as `toc:nil`, `num:nil`, or `title:nil`.
|
||||||
|
- `#+COMMENTS: t`: injects the comment UI and adds the page to the comment manifest.
|
||||||
|
- `#+SLUG`: stable comment API identifier; defaults to the filename when omitted.
|
||||||
|
- `#+NO_SIDENOTES`: any non-empty value adds the `no-sidenotes` body class. The `NO_SIDENOTES` or `KANBAN` file tag and a heading containing `KANBAN` do the same.
|
||||||
|
|
||||||
|
Use root-relative URLs (`/assets/...`) for shared site assets. Relative Org file links are rewritten to their corresponding HTML links by the exporter.
|
||||||
|
|
||||||
|
### Supported export helpers
|
||||||
|
|
||||||
|
Mermaid diagrams can be written directly in Org:
|
||||||
|
|
||||||
|
```org
|
||||||
|
#+begin_src mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[Write] --> B[Publish]
|
||||||
|
#+end_src
|
||||||
|
```
|
||||||
|
|
||||||
|
The custom HTML backend emits a Mermaid container, and the shared browser initializer renders it. The build also provides these Org macros:
|
||||||
|
|
||||||
|
- `{{{sidenote(id,text)}}}`
|
||||||
|
- `{{{epigraph(quote,attribution)}}}` and related epigraph variants
|
||||||
|
- `{{{kbd(text)}}}`
|
||||||
|
- `{{{margimg(url,alt,caption)}}}`
|
||||||
|
- `{{{countdown(datetime,label)}}}`
|
||||||
|
|
||||||
|
### Where new pages belong
|
||||||
|
|
||||||
|
- Blog entry: `blogs/YYYY/MM-month/slug.org`.
|
||||||
|
- General post: `posts/slug.org`.
|
||||||
|
- Career post: `posts/career/slug.org`.
|
||||||
|
- Utility/dashboard page: `home/slug.org`.
|
||||||
|
- Interactive experiment: `play/slug.org`, usually paired with `assets/scripts/pages/slug.js` and `assets/styles/pages/slug.css`.
|
||||||
|
- Lima page: `.org` or `.md` under `lima/`.
|
||||||
|
|
||||||
|
Do not manually maintain generated list, category, recent, tag, or output files; rebuild them from source.
|
||||||
|
|
||||||
|
### Lima Markdown publishing
|
||||||
|
|
||||||
|
Markdown under `lima/` is converted to temporary Org with Pandoc, given generated title/date/comment metadata, and then exported through the normal HTML backend. The first Markdown H1 becomes the page title. Sibling directories matching `.attachments.*` are copied beside the published page. Org files in `lima/` use the same attachment-copy behavior without Pandoc.
|
||||||
|
|
||||||
|
## Frontend architecture
|
||||||
|
|
||||||
|
All static browser assets live under `assets/` and are copied to `output/assets/`. `lisp/build-assets.el` is the authoritative list of globally loaded styles and scripts; it appends deterministic mtime/size query strings for cache busting.
|
||||||
|
|
||||||
|
### Styles
|
||||||
|
|
||||||
|
The modular CSS hierarchy is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
assets/styles/
|
||||||
|
├── themes/ design tokens and themes
|
||||||
|
├── base/ element defaults
|
||||||
|
├── layout/ site shell and layout
|
||||||
|
├── components/ reusable UI components
|
||||||
|
├── pages/ page-specific presentation
|
||||||
|
├── features/ optional feature presentation
|
||||||
|
├── utilities/ responsive/utility rules
|
||||||
|
└── vendor/ third-party CSS
|
||||||
|
```
|
||||||
|
|
||||||
|
Top-level CSS files are compatibility manifests that import the modular files. Prefer editing the modular source, while preserving the top-level entry points until old handwritten references have been removed.
|
||||||
|
|
||||||
|
### Scripts
|
||||||
|
|
||||||
|
The intended JavaScript boundaries are:
|
||||||
|
|
||||||
|
```text
|
||||||
|
assets/scripts/
|
||||||
|
├── application/ cross-page use-case orchestration
|
||||||
|
├── ui/ DOM presentation behavior
|
||||||
|
├── pages/ page-specific applications
|
||||||
|
├── features/ optional site-wide features
|
||||||
|
└── vendor/ vendored third-party libraries
|
||||||
|
```
|
||||||
|
|
||||||
|
The shared script list is loaded on every page, so each module must safely exit when its expected DOM is absent. Page modules should own page composition; reusable browser behavior belongs in `ui/` or `features/`. Top-level duplicate filenames are legacy loaders or compatibility copies—edit the modular file referenced by `build-assets.el` unless a page explicitly loads the legacy path.
|
||||||
|
|
||||||
|
Third-party browser dependencies are vendored: Lunr, Mermaid, BiggerPicture, and SVG Pan Zoom. Avoid editing minified vendor files directly.
|
||||||
|
|
||||||
|
## Dynamic features and service boundaries
|
||||||
|
|
||||||
|
The published HTML is static, but some browser modules rely on same-origin APIs:
|
||||||
|
|
||||||
|
| Feature | Browser module | Runtime endpoint |
|
||||||
|
|----------------------|----------------------------------|--------------------------------------------------|
|
||||||
|
| Comments and replies | `features/comments.js` | `GET /api/comments/:slug`, `POST /api/comments` |
|
||||||
|
| Notes board | `pages/notes.js` | `/api/notes` |
|
||||||
|
| Wird tracker | `pages/wird-tracker.js` | `/api/wird`, `/api/wird/motalah`, `/api/calibre/books` |
|
||||||
|
| Competency board | `pages/competency-status-board.js` | `/api/competencies/items...` |
|
||||||
|
| Resource Loader | `pages/resource-loader.js` | `/api/resource-loader`, `/api/resource-loader/thumbnails/:id` |
|
||||||
|
|
||||||
|
These APIs are not implemented here. Production routing must serve `output/` and proxy `/api/` to the appropriate backend. When changing an endpoint contract, coordinate the static client and its external service.
|
||||||
|
|
||||||
|
The hidden-details feature uses browser `localStorage`/`sessionStorage` for visit and discovery state and may call Open-Meteo after explicit geolocation interaction. Its friendly content source is `assets/content/hidden-details.json`; `assets/scripts/features/hidden-details.js` is generated by the external hidden-narrative authoring workflow. Treat both files as a pair and do not hand-edit only the generated JavaScript.
|
||||||
|
|
||||||
|
The Resource Loader at `/rl/` uses the same-origin `/api/resource-loader` backend as its sole metadata source, with ETags protecting concurrent edits. There is no browser metadata fallback. Generated cover thumbnails use `/api/resource-loader/thumbnails/:id` and are removed from local storage after upload. IndexedDB under `zxh-resource-loader` retains only imported PDF binaries on the device where they were added. JSON exports contain metadata and reading sessions, not PDF binaries. Its page code lives in `assets/scripts/pages/resource-loader.js`; the separately testable schema/progress/sync operations live in `resource-loader-model.js` and run with `make test-js`.
|
||||||
|
|
||||||
|
## Authoring service
|
||||||
|
|
||||||
|
The browser authoring UI is a separate sibling repository at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/home/zaine/master-folder/org-platform/authoring-service
|
||||||
|
```
|
||||||
|
|
||||||
|
It creates and edits the normal source files described above, stores Lima uploads under `assets/images/hzone/YYYY/MM/`, and enqueues site builds after saves. It is available locally at `http://127.0.0.1:8765` and, behind the production reverse proxy, at `https://author.zainezq.com`.
|
||||||
|
|
||||||
|
The Makefile delegates these commands to that repository:
|
||||||
|
|
||||||
|
- `make author` / `make author-start`
|
||||||
|
- `make author-install`
|
||||||
|
- `make author-stop`
|
||||||
|
- `make author-restart`
|
||||||
|
- `make author-health`
|
||||||
|
- `make author-status`
|
||||||
|
- `make author-diagnostics`
|
||||||
|
- `make author-logs`
|
||||||
|
|
||||||
|
They depend on the sibling repository existing at the hard-coded path and use its user systemd service.
|
||||||
|
|
||||||
|
## Testing and verification
|
||||||
|
|
||||||
|
Run the publishing tests:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make test-elisp
|
||||||
|
```
|
||||||
|
|
||||||
|
For a documentation/content-only change, a dry run validates package loading and generated inputs without rewriting all HTML:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SITE_DRY=1 emacs -Q --script build-site.el
|
||||||
|
```
|
||||||
|
|
||||||
|
For build or frontend changes, use a clean build, generate the search index, serve `output/`, and inspect the relevant pages in a browser:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SITE_CLEAN=1 emacs -Q --script build-site.el
|
||||||
|
.venv/bin/python search-index-json.py
|
||||||
|
python3 -m http.server 8000 --directory output
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify at least:
|
||||||
|
|
||||||
|
- the home page and global navigation;
|
||||||
|
- a blog/post with a table of contents, tags, sidenotes, and comments as applicable;
|
||||||
|
- the relevant special page under `home/` or `play/`;
|
||||||
|
- search results and keyboard navigation;
|
||||||
|
- browser console/network failures;
|
||||||
|
- the generated sitemap and tag/category pages.
|
||||||
|
|
||||||
|
## Deployment and CI
|
||||||
|
|
||||||
|
`.gitea/workflows/build.yml` runs on pushes to `main`, nightly at midnight, and manual dispatch. Push events that only change the hidden-details JSON/generated JS pair are ignored to prevent a build loop.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Git as Gitea main
|
||||||
|
participant Runner as org-web runner
|
||||||
|
participant Build as Site build
|
||||||
|
participant Monitor as PowerShell monitor
|
||||||
|
Git->>Runner: push, schedule, or manual dispatch
|
||||||
|
opt scheduled or manual
|
||||||
|
Runner->>Git: commit and push hidden-detail changes if present
|
||||||
|
end
|
||||||
|
Runner->>Build: SITE_CLEAN=1 make
|
||||||
|
Build-->>Runner: static output and logs
|
||||||
|
Runner->>Monitor: inspect result and authoring tests
|
||||||
|
Monitor-->>Runner: Discord notification
|
||||||
|
```
|
||||||
|
|
||||||
|
The runner is host-specific. It expects:
|
||||||
|
|
||||||
|
- the checkout at `/home/zaine/master-folder/org-platform/org_web`;
|
||||||
|
- output at that repository's `output/` directory;
|
||||||
|
- the permission helper referenced by the Makefile;
|
||||||
|
- PowerShell at `/snap/bin/pwsh`;
|
||||||
|
- the sibling authoring-service repository;
|
||||||
|
- Gitea/Discord credentials supplied as Actions secrets.
|
||||||
|
|
||||||
|
The workflow's monitor writes diagnostics and uses `DISCORD_WEBHOOK_URL` and `API_KEY`. Never commit those values or other service credentials.
|
||||||
|
|
||||||
|
## Safe change checklist
|
||||||
|
|
||||||
|
1. Edit source content, modular assets, or `lisp/`; do not edit `output/` as the source of a fix.
|
||||||
|
2. Check whether the file is generated or a compatibility wrapper before changing it.
|
||||||
|
3. Add new globally loaded assets to `lisp/build-assets.el`; keep page-only behavior scoped to its page when possible.
|
||||||
|
4. Preserve stable `#+SLUG` values on comment-enabled pages.
|
||||||
|
5. Run `make test-elisp` and the smallest representative build/browser checks for the change.
|
||||||
|
6. Review `git status` carefully: builds intentionally update generated Org files and manifests, while local logs and caches may also change.
|
||||||
|
7. Commit only the intended source and generated artifacts required by the workflow.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### A build skips files that are missing from `output/`
|
||||||
|
|
||||||
|
Run with `SITE_CLEAN=1`. The normal integrity check catches a missing asset directory or entry page, but it does not validate every generated page.
|
||||||
|
|
||||||
|
### `htmlize` cannot be installed
|
||||||
|
|
||||||
|
The first build needs access to MELPA/ELPA unless `.packages/` already contains it. Restore network access or provide the cached package; the build cannot continue without syntax-highlighting support.
|
||||||
|
|
||||||
|
### Lima Markdown fails to publish
|
||||||
|
|
||||||
|
Confirm `pandoc` is installed and available on `PATH`. Plain Org publishing does not require Pandoc.
|
||||||
|
|
||||||
|
### Search is unavailable
|
||||||
|
|
||||||
|
Confirm `output/search-index.json` exists and the site is served over HTTP. Run `make search` after publishing HTML.
|
||||||
|
|
||||||
|
### Interactive data does not load locally
|
||||||
|
|
||||||
|
Static file serving does not provide `/api/...`. Run/proxy the relevant external services, or expect those features to show their empty/error state.
|
||||||
|
|
||||||
|
### The full `make` command fails outside the production host
|
||||||
|
|
||||||
|
`make` runs a hard-coded permission helper with `sudo`. Use `make build` followed by `make search`, or the portable commands above, on another machine.
|
||||||
|
|||||||
567
assets/content/comment-pages.json
Executable file
@@ -0,0 +1,567 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"slug": "benefits-of-reading",
|
||||||
|
"title": "Benefits of Reading",
|
||||||
|
"url": "/blogs/2025/08-august/benefits-of-reading.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "hilberts-hotel",
|
||||||
|
"title": "Hilbert's Hotel",
|
||||||
|
"url": "/blogs/2025/08-august/hilberts.hotel.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "spending-all-day-on-this-website",
|
||||||
|
"title": "09-08-2025: Website Changes",
|
||||||
|
"url": "/blogs/2025/08-august/spending-the-whole-day-on-this-website.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "third-time",
|
||||||
|
"title": "Third Time",
|
||||||
|
"url": "/blogs/2025/08-august/third-time.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "wacom-with-arch",
|
||||||
|
"title": "Wacom With Arch",
|
||||||
|
"url": "/blogs/2025/08-august/wacom-with-arch.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "what-i-want-to-do-with-emacs",
|
||||||
|
"title": "What Do I Want To Do With Emacs",
|
||||||
|
"url": "/blogs/2025/08-august/what-do-i-want-to-do-with-emacs.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "zettlekasten",
|
||||||
|
"title": "Zettelkasten Method",
|
||||||
|
"url": "/blogs/2025/08-august/zettelkasten.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "02-11-25-week-review",
|
||||||
|
"title": "[02-11-2025] - Weekly Review",
|
||||||
|
"url": "/blogs/2025/11-november/02-11-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "09-11-25-week-review",
|
||||||
|
"title": "[09-11-2025] - Weekly Review",
|
||||||
|
"url": "/blogs/2025/11-november/09-11-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "16-11-25-week-review",
|
||||||
|
"title": "[16-11-2025] - Weekly Review",
|
||||||
|
"url": "/blogs/2025/11-november/16-11-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "23-11-25-week-review",
|
||||||
|
"title": "[23-11-2025] - Weekly Review",
|
||||||
|
"url": "/blogs/2025/11-november/23-11-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "30-11-25-week-review",
|
||||||
|
"title": "[30-11-2025] - Weekly Review",
|
||||||
|
"url": "/blogs/2025/11-november/30-11-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "07-12-25-week-review",
|
||||||
|
"title": "[07-12-2025] - Weekly Review",
|
||||||
|
"url": "/blogs/2025/12-december/07-12-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "14-12-25-week-review",
|
||||||
|
"title": "[14-12-2025] - Weekly Review",
|
||||||
|
"url": "/blogs/2025/12-december/14-12-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "21-12-25-week-review",
|
||||||
|
"title": "[21-12-2025] - Weekly Review",
|
||||||
|
"url": "/blogs/2025/12-december/21-12-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "28-12-25-week-review",
|
||||||
|
"title": "[28-12-2025] - Weekly Review",
|
||||||
|
"url": "/blogs/2025/12-december/28-12-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "04-01-26-week-review",
|
||||||
|
"title": "[04-01-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/01-january/04-01-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "11-01-26-week-review",
|
||||||
|
"title": "[11-01-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/01-january/11-01-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "18-01-26-week-review",
|
||||||
|
"title": "[18-01-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/01-january/18-01-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "25-01-26-week-review",
|
||||||
|
"title": "[25-01-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/01-january/25-01-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "01-02-26-week-review",
|
||||||
|
"title": "[01-02-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/02-february/01-02-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "08-02-26-week-review",
|
||||||
|
"title": "[08-02-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/02-february/08-02-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "15-02-26-week-review",
|
||||||
|
"title": "[15-02-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/02-february/15-02-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "22-02-26-week-review",
|
||||||
|
"title": "[22-02-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/02-february/22-02-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "24-02-26",
|
||||||
|
"title": "Integration tests failing (sob)",
|
||||||
|
"url": "/blogs/2026/02-february/24-02-26.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "26-02-26",
|
||||||
|
"title": "Starting the Journeys Upgrade",
|
||||||
|
"url": "/blogs/2026/02-february/26-02-26.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "27-02-26",
|
||||||
|
"title": "Journeys rambles again...",
|
||||||
|
"url": "/blogs/2026/02-february/27-02-26.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "third-meeting-with-lima",
|
||||||
|
"title": "Third Meeting with lima :)",
|
||||||
|
"url": "/blogs/2026/02-february/third-meeting.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "01-03-26-week-review",
|
||||||
|
"title": "[01-03-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/03-march/01-03-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "08-03-26-week-review",
|
||||||
|
"title": "[08-03-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/03-march/08-03-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "15-03-26-week-review",
|
||||||
|
"title": "[15-03-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/03-march/15-03-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "22-03-26-week-review",
|
||||||
|
"title": "[22-03-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/03-march/22-03-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "29-03-26-week-review",
|
||||||
|
"title": "[29-03-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/03-march/29-03-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "cooking-dinner-29-03-26",
|
||||||
|
"title": "Cooking Dinner (num)",
|
||||||
|
"url": "/blogs/2026/03-march/cooking-dinner-29-03-26.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "feeling-sleepy",
|
||||||
|
"title": "Feeling extremely sleepy",
|
||||||
|
"url": "/blogs/2026/03-march/feeling-sleepy.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "fixing-the-dag-18-03",
|
||||||
|
"title": "DAG fixes",
|
||||||
|
"url": "/blogs/2026/03-march/fixing-the-dag-18-03.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "installing-emacs-23-03",
|
||||||
|
"title": "Installing Emacs on Windows",
|
||||||
|
"url": "/blogs/2026/03-march/installing-emacs-23-03.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "intellectually-challenging-myself-30-03-26",
|
||||||
|
"title": "Intellectually challenging oneself",
|
||||||
|
"url": "/blogs/2026/03-march/intellectually-challenging-myself-30-03-26.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "more-thoughts-regarding-changes-26-03-26",
|
||||||
|
"title": "More thoughts on schedule changes",
|
||||||
|
"url": "/blogs/2026/03-march/more-thoughts-regarding-changes-26-03-26.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "oversleeping-16-03",
|
||||||
|
"title": "Oversleeping and missing a meeting...",
|
||||||
|
"url": "/blogs/2026/03-march/oversleeping-16-03.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "quick-lunch-24-03",
|
||||||
|
"title": "Making Lunch QUiCk!!",
|
||||||
|
"url": "/blogs/2026/03-march/quick-lunch-24-03.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "setting-a-wird-tracker-19-03",
|
||||||
|
"title": "Setting a Wird Tracker",
|
||||||
|
"url": "/blogs/2026/03-march/setting-a-wird-tracker-19-03.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "thoughts-on-life-25-03-26",
|
||||||
|
"title": "Thoughts on life...",
|
||||||
|
"url": "/blogs/2026/03-march/thoughts-on-life-25-03-26.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "training-on-friday-afternoon-27-03-26",
|
||||||
|
"title": "Training time on Friday afternoon",
|
||||||
|
"url": "/blogs/2026/03-march/training-on-friday-afternoon-27-03-26.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "05-04-26-week-review",
|
||||||
|
"title": "[05-04-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/04-april/05-04-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "12-04-26-week-review",
|
||||||
|
"title": "[12-04-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/04-april/12-04-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "16-04-26",
|
||||||
|
"title": "Rambles",
|
||||||
|
"url": "/blogs/2026/04-april/16-04-26.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "19-04-26-week-review",
|
||||||
|
"title": "[19-04-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/04-april/19-04-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "26-04-26-week-review",
|
||||||
|
"title": "[26-04-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/04-april/26-04-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "action-plan-07-04-26",
|
||||||
|
"title": "Action plan to change teams",
|
||||||
|
"url": "/blogs/2026/04-april/action-plan-07-04-26.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "adding-more-wird-22-04-26",
|
||||||
|
"title": "Adding another Wird",
|
||||||
|
"url": "/blogs/2026/04-april/adding-more-wird-22-04-26.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "april-almost-over-28-04",
|
||||||
|
"title": "April is almost over...",
|
||||||
|
"url": "/blogs/2026/04-april/april-almost-over-28-04.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "comparison-14-04",
|
||||||
|
"title": "Comparison is the thief of joy",
|
||||||
|
"url": "/blogs/2026/04-april/comparison-14-04.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "ending-the-week-17-04-26",
|
||||||
|
"title": "End of week thoughts",
|
||||||
|
"url": "/blogs/2026/04-april/ending-the-week-17-04-26.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "joining-new-team-meeting-02-04-2026",
|
||||||
|
"title": "New team meeting",
|
||||||
|
"url": "/blogs/2026/04-april/joining-new-team-meeting-02-04-2026.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "making-notes-through-org-noter-06-05",
|
||||||
|
"title": "Making Notes through Org Noter",
|
||||||
|
"url": "/blogs/2026/04-april/making-notes-through-org-noter-06-05.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "sitting-outside-in-the-sun-21-04-26",
|
||||||
|
"title": "Sitting in the sun",
|
||||||
|
"url": "/blogs/2026/04-april/sitting-outside-in-the-sun-21-04-26.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "starting-new-rotation-08-04-26",
|
||||||
|
"title": "Starting new rotation",
|
||||||
|
"url": "/blogs/2026/04-april/starting-new-rotation-08-04-26.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "weekly-target-06-04-26",
|
||||||
|
"title": "[06-04-2026] - Weekly target",
|
||||||
|
"url": "/blogs/2026/04-april/weekly-target-06-04-26.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "wise-words-29-04",
|
||||||
|
"title": "Wise Words I need to engrain",
|
||||||
|
"url": "/blogs/2026/04-april/wise-words-29-04.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "03-05-26-week-review",
|
||||||
|
"title": "[03-05-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/05-may/03-05-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "10-05-26-week-review",
|
||||||
|
"title": "[10-05-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/05-may/10-05-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "17-05-26-week-review",
|
||||||
|
"title": "[17-05-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/05-may/17-05-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "24-05-26-week-review",
|
||||||
|
"title": "[24-05-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/05-may/24-05-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "31-05-26-week-review",
|
||||||
|
"title": "[31-05-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/05-may/31-05-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "ai-datacamp-08-05",
|
||||||
|
"title": "AI Datacamp",
|
||||||
|
"url": "/blogs/2026/05-may/ai-datacamp-08-05.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "authoring-service-09-05-26",
|
||||||
|
"title": "Authoring Service",
|
||||||
|
"url": "/blogs/2026/05-may/authoring-service-09-05-26.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "back-to-back-meetings-13-05",
|
||||||
|
"title": "Back to back meetings",
|
||||||
|
"url": "/blogs/2026/05-may/back-to-back-meetings-13-05.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "characters-12-05",
|
||||||
|
"title": "Meet the characters!",
|
||||||
|
"url": "/blogs/2026/05-may/characters-12-05.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "having-a-daily-checkpoint-s-12-05",
|
||||||
|
"title": "Having a daily checkpoint(s)",
|
||||||
|
"url": "/blogs/2026/05-may/having-a-daily-checkpoint-s-12-05.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "memory-observatory-12-05",
|
||||||
|
"title": "Memory Observatory",
|
||||||
|
"url": "/blogs/2026/05-may/memory-observatory-12-05.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "poppadoms-num-13-05",
|
||||||
|
"title": "Poppadoms num",
|
||||||
|
"url": "/blogs/2026/05-may/poppadoms-num-13-05.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "sun-soak-01-05",
|
||||||
|
"title": "Soaking in the sun",
|
||||||
|
"url": "/blogs/2026/05-may/sun-soak-01-05.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "using-codex-07-05-26",
|
||||||
|
"title": "Using Codex",
|
||||||
|
"url": "/blogs/2026/05-may/using-codex-07-05-26.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "using-gifs-13-05",
|
||||||
|
"title": "Sans spins for us!",
|
||||||
|
"url": "/blogs/2026/05-may/using-gifs-13-05.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "working-on-eid-day-27-05-26",
|
||||||
|
"title": "Working on Eid day",
|
||||||
|
"url": "/blogs/2026/05-may/working-on-eid-day-27-05-26.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "14-06-26-week-review",
|
||||||
|
"title": "[14-06-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/06-june/14-06-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "21-06-26-week-review",
|
||||||
|
"title": "[21-06-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/06-june/21-06-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "28-06-26-week-review",
|
||||||
|
"title": "[28-06-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/06-june/28-06-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "cleaning-up-the-repos",
|
||||||
|
"title": "Cleaning up the repos",
|
||||||
|
"url": "/blogs/2026/06-june/cleaning-up-the-repos.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "05-07-26-week-review",
|
||||||
|
"title": "[05-07-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/07-july/05-07-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "12-07-26-week-review",
|
||||||
|
"title": "[12-07-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/07-july/12-07-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "19-07-26-week-review",
|
||||||
|
"title": "[19-07-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/07-july/19-07-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "26-07-26-week-review",
|
||||||
|
"title": "[26-07-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/07-july/26-07-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "02-08-26-week-review",
|
||||||
|
"title": "[02-08-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/08-august/02-08-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "09-08-26-week-review",
|
||||||
|
"title": "[09-08-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/08-august/09-08-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "16-08-26-week-review",
|
||||||
|
"title": "[16-08-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/08-august/16-08-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "publish-pages",
|
||||||
|
"title": "How to publish pages using Org Publish",
|
||||||
|
"url": "/blogs/publish-pages.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "setup",
|
||||||
|
"title": "Setup",
|
||||||
|
"url": "/home/guide/setup.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "backlog",
|
||||||
|
"title": "Backlog",
|
||||||
|
"url": "/home/backlog.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "wedding-countdown",
|
||||||
|
"title": "Countdown",
|
||||||
|
"url": "/home/countdown.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "notes",
|
||||||
|
"title": "Notes",
|
||||||
|
"url": "/home/notes.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "services",
|
||||||
|
"title": "Service",
|
||||||
|
"url": "/home/services.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "competency-board",
|
||||||
|
"title": "Competency Status Board",
|
||||||
|
"url": "/home/status.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "airflow",
|
||||||
|
"title": "Datamarts, Airflow and DAG's",
|
||||||
|
"url": "/posts/career/airflow.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "career-intro",
|
||||||
|
"title": "Career Introduction",
|
||||||
|
"url": "/posts/career/career-intro.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "cross-site-scripting-xss",
|
||||||
|
"title": "Cross-Site Scripting (XSS)",
|
||||||
|
"url": "/posts/career/cross-site-scripting-xss.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "database-permissions",
|
||||||
|
"title": "Database Permissions, Roles, and Accounts",
|
||||||
|
"url": "/posts/career/database-permissions.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "high-availability-disaster-recovery",
|
||||||
|
"title": "High Availability, Disaster Recovery and Business Continuity",
|
||||||
|
"url": "/posts/career/ha-dr.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "invest-principles",
|
||||||
|
"title": "Invest Principles",
|
||||||
|
"url": "/posts/career/invest-principles.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "understands-the-javascript-language",
|
||||||
|
"title": "Understands the Javascript language",
|
||||||
|
"url": "/posts/career/javascript.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "lean",
|
||||||
|
"title": "Lean",
|
||||||
|
"url": "/posts/career/lean.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "management-of-self",
|
||||||
|
"title": "Management of self training",
|
||||||
|
"url": "/posts/career/management-of-self.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "monitoring-and-logging",
|
||||||
|
"title": "Monitoring and Logging",
|
||||||
|
"url": "/posts/career/monitoring-and-logging.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "normalisation",
|
||||||
|
"title": "Benefits of Normalisation",
|
||||||
|
"url": "/posts/career/normalisation.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "owasp-top-10",
|
||||||
|
"title": "OWASP Top Ten",
|
||||||
|
"url": "/posts/career/owasp.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "pipelines-learning",
|
||||||
|
"title": "Pipelines and how they work (as well as CI/CD)",
|
||||||
|
"url": "/posts/career/pipelines.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "probation-objectives",
|
||||||
|
"title": "Probation Objectives:",
|
||||||
|
"url": "/posts/career/probation-objectives.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "requirements-features",
|
||||||
|
"title": "Requirements, features, user stories, tasks, walking skeletons",
|
||||||
|
"url": "/posts/career/requirements-features.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "restful-api",
|
||||||
|
"title": "Restful API",
|
||||||
|
"url": "/posts/career/restful-api.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "retrospectives",
|
||||||
|
"title": "Retrospectives",
|
||||||
|
"url": "/posts/career/retrospectives.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "solid-principles",
|
||||||
|
"title": "SOLID Principles",
|
||||||
|
"url": "/posts/career/solid-principles.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "wireframe-designs",
|
||||||
|
"title": "Wireframe Designs",
|
||||||
|
"url": "/posts/career/wireframe-designs.html"
|
||||||
|
}
|
||||||
|
]
|
||||||
1785
assets/content/hidden-details.json
Executable file
23
assets/content/resource-loader.json
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"vaults": [
|
||||||
|
{
|
||||||
|
"id": "vault-work",
|
||||||
|
"name": "Work",
|
||||||
|
"colour": "#526d82",
|
||||||
|
"folders": [],
|
||||||
|
"resources": [],
|
||||||
|
"createdAt": "2026-08-19T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-08-19T00:00:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vault-personal-study",
|
||||||
|
"name": "Personal Study",
|
||||||
|
"colour": "#9b6b43",
|
||||||
|
"folders": [],
|
||||||
|
"resources": [],
|
||||||
|
"createdAt": "2026-08-19T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-08-19T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
0
assets/fonts/NotoSans-Regular.ttf
Normal file → Executable file
0
assets/fonts/Roboto-Regular.ttf
Normal file → Executable file
0
assets/fonts/times.ttf
Normal file → Executable file
BIN
assets/icons/pen-icon.png
Executable file
|
After Width: | Height: | Size: 5.0 KiB |
0
assets/images/240226-pr.png
Normal file → Executable file
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
0
assets/images/26-02-26-di-errors-2.png
Normal file → Executable file
|
Before Width: | Height: | Size: 265 KiB After Width: | Height: | Size: 265 KiB |
0
assets/images/26-02-26-di-errors.png
Normal file → Executable file
|
Before Width: | Height: | Size: 324 KiB After Width: | Height: | Size: 324 KiB |
0
assets/images/27-02-26-xserver.png
Normal file → Executable file
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 118 KiB |
0
assets/images/blogs/2026-03-12-journeys-branch.png
Normal file → Executable file
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
0
assets/images/blogs/2026-03-18-slack-dag-manager.png
Normal file → Executable file
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
BIN
assets/images/blogs/2026-03-23-install-emacs.png
Executable file
|
After Width: | Height: | Size: 36 KiB |
BIN
assets/images/blogs/2026-03-24-lunch.jpg
Executable file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
assets/images/blogs/2026-03-29-dinner-1.jpg
Executable file
|
After Width: | Height: | Size: 930 KiB |
BIN
assets/images/blogs/2026-03-29-dinner-2.jpg
Executable file
|
After Width: | Height: | Size: 942 KiB |
BIN
assets/images/blogs/2026-03-29-dinner-3.jpg
Executable file
|
After Width: | Height: | Size: 661 KiB |
BIN
assets/images/blogs/2026-04-02-slack.png
Executable file
|
After Width: | Height: | Size: 170 KiB |
BIN
assets/images/blogs/2026-05-01-sun.jpg
Executable file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
assets/images/blogs/2026-05-08-ai-pubs-history-2.webp
Executable file
|
After Width: | Height: | Size: 23 KiB |
BIN
assets/images/blogs/2026-05-08-time-to-100m-pic.webp
Executable file
|
After Width: | Height: | Size: 61 KiB |
BIN
assets/images/blogs/2026-05-09-authoring-service.png
Executable file
|
After Width: | Height: | Size: 334 KiB |
BIN
assets/images/blogs/2026-05-09-discord-alterts-gitea.png
Executable file
|
After Width: | Height: | Size: 134 KiB |
BIN
assets/images/blogs/2026-05-09-gitea-pipelines.png
Executable file
|
After Width: | Height: | Size: 127 KiB |
BIN
assets/images/blogs/2026-05-12-lima.jpg
Executable file
|
After Width: | Height: | Size: 123 KiB |
BIN
assets/images/blogs/2026-05-12-screenshot-2026-05-12-163301.png
Executable file
|
After Width: | Height: | Size: 860 KiB |
BIN
assets/images/blogs/2026-05-13-200.gif
Executable file
|
After Width: | Height: | Size: 343 KiB |
BIN
assets/images/blogs/2026-05-13-image.jpg
Executable file
|
After Width: | Height: | Size: 307 KiB |
BIN
assets/images/blogs/2026-05-13-poppadoms.jpg
Executable file
|
After Width: | Height: | Size: 217 KiB |
BIN
assets/images/blogs/2026-05-13-screenshot-2026-05-13-131807.png
Executable file
|
After Width: | Height: | Size: 78 KiB |
BIN
assets/images/blogs/2026-05-13-spinning-spin.webp
Executable file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
assets/images/blogs/2026-05-27-screenshot-2026-05-27-160508.png
Executable file
|
After Width: | Height: | Size: 76 KiB |
BIN
assets/images/blogs/2026-06-04-screenshot-2026-06-04-143726.png
Executable file
|
After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 929 KiB After Width: | Height: | Size: 929 KiB |
|
Before Width: | Height: | Size: 291 KiB After Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 508 KiB After Width: | Height: | Size: 508 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
BIN
assets/images/career/2025-12-02-retros.jpg
Executable file
|
After Width: | Height: | Size: 284 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
0
assets/images/career/05-03-26-api-protocols.png → assets/images/career/2026-03-05-api-protocols.png
Normal file → Executable file
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
BIN
assets/images/career/2026-06-01-xss-attack.webp
Executable file
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
BIN
assets/images/hzone/2026-05-09-screen-shot-2025-11-10-at-18-02-10-fullpage.png
Executable file
|
After Width: | Height: | Size: 71 KiB |
0
assets/images/lima-third-meeting.jpg
Normal file → Executable file
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 254 KiB After Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 230 KiB After Width: | Height: | Size: 230 KiB |
0
assets/images/reviews/competency-powerbi-report.png
Normal file → Executable file
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 121 KiB |
0
assets/images/reviews/journeys-api-side-by-side-upgrade.png
Normal file → Executable file
|
Before Width: | Height: | Size: 179 KiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
0
assets/images/reviews/timesheets/timesheet-15-02-26.png → assets/images/reviews/timesheets/2026-02-15-timesheet.png
Normal file → Executable file
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
0
assets/images/reviews/timesheets/timesheet-22-02-26.png → assets/images/reviews/timesheets/2026-02-22-timesheet.png
Normal file → Executable file
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
0
assets/images/reviews/timesheets/timesheet-01-03-26.png → assets/images/reviews/timesheets/2026-03-01-timesheet.png
Normal file → Executable file
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
0
assets/images/reviews/timesheets/timesheet-08-03-26.png → assets/images/reviews/timesheets/2026-03-08-timesheet.png
Normal file → Executable file
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
0
assets/images/reviews/timesheets/2026-03-15-timesheet.png
Normal file → Executable file
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
BIN
assets/images/reviews/timesheets/2026-03-22-timesheet.png
Executable file
|
After Width: | Height: | Size: 19 KiB |
BIN
assets/images/reviews/timesheets/2026-03-29-timesheet.png
Executable file
|
After Width: | Height: | Size: 21 KiB |
BIN
assets/images/reviews/timesheets/2026-04-05-timesheet.png
Executable file
|
After Width: | Height: | Size: 18 KiB |
BIN
assets/images/reviews/timesheets/2026-04-12-timesheet.png
Executable file
|
After Width: | Height: | Size: 20 KiB |
BIN
assets/images/reviews/timesheets/2026-04-19-timesheet.png
Executable file
|
After Width: | Height: | Size: 21 KiB |
BIN
assets/images/reviews/timesheets/2026-04-26-timesheet.png
Executable file
|
After Width: | Height: | Size: 20 KiB |
BIN
assets/images/reviews/timesheets/2026-05-03-timesheet.png
Executable file
|
After Width: | Height: | Size: 22 KiB |
BIN
assets/images/reviews/timesheets/2026-05-10-timesheet.png
Executable file
|
After Width: | Height: | Size: 58 KiB |
BIN
assets/images/reviews/timesheets/2026-05-17-timesheet.png
Executable file
|
After Width: | Height: | Size: 51 KiB |
BIN
assets/images/reviews/timesheets/2026-05-24-timesheet.png
Executable file
|
After Width: | Height: | Size: 64 KiB |
BIN
assets/images/reviews/timesheets/2026-05-31-timesheet.png
Executable file
|
After Width: | Height: | Size: 73 KiB |
32
assets/scripts/application/theme-switcher.js
Executable file
@@ -0,0 +1,32 @@
|
|||||||
|
/* Function for setting the theme */
|
||||||
|
(function(){
|
||||||
|
const root = document.documentElement;
|
||||||
|
const storageKey = "theme";
|
||||||
|
const themes = ["light", "dark", "dark-academia"];
|
||||||
|
const labels = {
|
||||||
|
"light": "Light",
|
||||||
|
"dark": "Dark",
|
||||||
|
"dark-academia": "Academia"
|
||||||
|
};
|
||||||
|
const saved = localStorage.getItem(storageKey);
|
||||||
|
if (themes.includes(saved)) {
|
||||||
|
root.setAttribute("data-theme", saved);
|
||||||
|
}
|
||||||
|
const btn = document.getElementById("theme-toggle");
|
||||||
|
if (!btn) return;
|
||||||
|
const updateButton = () => {
|
||||||
|
const current = root.getAttribute("data-theme");
|
||||||
|
const label = labels[current] || "Auto";
|
||||||
|
btn.textContent = `Theme: ${label}`;
|
||||||
|
btn.setAttribute("aria-label", `Current theme: ${label}. Switch theme.`);
|
||||||
|
};
|
||||||
|
updateButton();
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
const current = root.getAttribute("data-theme");
|
||||||
|
const index = themes.indexOf(current);
|
||||||
|
const target = themes[index === -1 ? 1 : (index + 1) % themes.length];
|
||||||
|
root.setAttribute("data-theme", target);
|
||||||
|
localStorage.setItem(storageKey, target);
|
||||||
|
updateButton();
|
||||||
|
});
|
||||||
|
})();
|
||||||
8
assets/scripts/bigger-picture.min.js
vendored
@@ -1,197 +1,7 @@
|
|||||||
let pageSlug = null;
|
(function () {
|
||||||
|
"use strict";
|
||||||
/* -----------------------------
|
var script = document.createElement("script");
|
||||||
* DOM builders
|
script.src = "/assets/scripts/features/comments.js";
|
||||||
* ----------------------------- */
|
script.defer = true;
|
||||||
|
document.head.appendChild(script);
|
||||||
|
}());
|
||||||
function showReplyForm(container, parentId) {
|
|
||||||
if (container.querySelector(".reply-form")) return;
|
|
||||||
|
|
||||||
const form = document.createElement("form");
|
|
||||||
form.className = "reply-form";
|
|
||||||
|
|
||||||
form.innerHTML = `
|
|
||||||
<input type="text" name="author" placeholder="Your name (optional)">
|
|
||||||
<textarea name="content" required placeholder="Write a reply..."></textarea>
|
|
||||||
<button type="submit">Post reply</button>
|
|
||||||
`;
|
|
||||||
|
|
||||||
form.addEventListener("submit", async e => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const author = form.author.value.trim();
|
|
||||||
const content = form.content.value.trim();
|
|
||||||
if (!content) return;
|
|
||||||
|
|
||||||
const ok = await postComment(author, content, parentId);
|
|
||||||
if (ok) loadComments();
|
|
||||||
else alert("Failed to post reply");
|
|
||||||
});
|
|
||||||
|
|
||||||
container.appendChild(form);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createComment(comment) {
|
|
||||||
const wrapper = document.createElement("div");
|
|
||||||
wrapper.className = "comment";
|
|
||||||
wrapper.dataset.id = comment.id;
|
|
||||||
|
|
||||||
const author = document.createElement("strong");
|
|
||||||
author.textContent = comment.author || "Anonymous";
|
|
||||||
|
|
||||||
const body = document.createElement("p");
|
|
||||||
body.textContent = comment.content;
|
|
||||||
|
|
||||||
const time = document.createElement("time");
|
|
||||||
time.className = "comment-date";
|
|
||||||
time.dateTime = comment.created_at;
|
|
||||||
time.textContent = new Date(comment.created_at).toLocaleString();
|
|
||||||
|
|
||||||
const replyBtn = document.createElement("button");
|
|
||||||
replyBtn.className = "comment-reply";
|
|
||||||
replyBtn.textContent = "Reply";
|
|
||||||
replyBtn.onclick = () =>
|
|
||||||
showReplyForm(wrapper, Number(comment.id));
|
|
||||||
|
|
||||||
wrapper.append(author, body, time, replyBtn);
|
|
||||||
|
|
||||||
if (comment.children?.length) {
|
|
||||||
const replies = document.createElement("div");
|
|
||||||
replies.className = "comment-children";
|
|
||||||
|
|
||||||
comment.children.forEach(child => {
|
|
||||||
replies.appendChild(createComment(child));
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.appendChild(replies);
|
|
||||||
}
|
|
||||||
console.log("PARENT_ID RECEIVED:", comment.parent_id)
|
|
||||||
|
|
||||||
return wrapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function buildCommentTree(comments) {
|
|
||||||
const byId = {};
|
|
||||||
const roots = [];
|
|
||||||
|
|
||||||
comments.forEach(comment => {
|
|
||||||
comment.children = [];
|
|
||||||
byId[comment.id] = comment;
|
|
||||||
});
|
|
||||||
|
|
||||||
comments.forEach(comment => {
|
|
||||||
if (comment.parent_id) {
|
|
||||||
const parent = byId[comment.parent_id];
|
|
||||||
if (parent) parent.children.push(comment);
|
|
||||||
} else {
|
|
||||||
roots.push(comment);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return roots;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* -----------------------------
|
|
||||||
* Rendering
|
|
||||||
* ----------------------------- */
|
|
||||||
|
|
||||||
function renderComments(comments) {
|
|
||||||
const list = document.getElementById("comments-list");
|
|
||||||
list.innerHTML = "";
|
|
||||||
|
|
||||||
if (!comments.length) {
|
|
||||||
const empty = document.createElement("p");
|
|
||||||
empty.className = "comments-empty";
|
|
||||||
empty.textContent = "No comments yet.";
|
|
||||||
list.appendChild(empty);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tree = buildCommentTree(comments);
|
|
||||||
tree.forEach(comment => list.appendChild(createComment(comment)));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* -----------------------------
|
|
||||||
* API
|
|
||||||
* ----------------------------- */
|
|
||||||
|
|
||||||
async function fetchComments() {
|
|
||||||
const res = await fetch(`/api/comments/${pageSlug}`);
|
|
||||||
if (!res.ok) {
|
|
||||||
console.error("Failed to fetch comments");
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return await res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function postComment(author, content, parentId = null) {
|
|
||||||
const res = await fetch("/api/comments", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
page_slug: pageSlug,
|
|
||||||
author: author || null,
|
|
||||||
content,
|
|
||||||
parent_id: parentId
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.ok;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* -----------------------------
|
|
||||||
* Form handling
|
|
||||||
* ----------------------------- */
|
|
||||||
|
|
||||||
function wireCommentForm() {
|
|
||||||
const form = document.getElementById("comment-form");
|
|
||||||
|
|
||||||
form.addEventListener("submit", async event => {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
const authorInput = form.querySelector("input[name='author']");
|
|
||||||
const textarea = form.querySelector("textarea[name='content']");
|
|
||||||
|
|
||||||
const author = authorInput.value.trim();
|
|
||||||
const content = textarea.value.trim();
|
|
||||||
|
|
||||||
if (!content) return;
|
|
||||||
|
|
||||||
const ok = await postComment(author, content);
|
|
||||||
if (ok) {
|
|
||||||
textarea.value = "";
|
|
||||||
loadComments();
|
|
||||||
} else {
|
|
||||||
alert("Failed to post comment");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* -----------------------------
|
|
||||||
* Entry point
|
|
||||||
* ----------------------------- */
|
|
||||||
|
|
||||||
async function loadComments() {
|
|
||||||
const comments = await fetchComments();
|
|
||||||
renderComments(comments);
|
|
||||||
}
|
|
||||||
|
|
||||||
function initComments() {
|
|
||||||
const section = document.getElementById("comments");
|
|
||||||
if (!section) return;
|
|
||||||
|
|
||||||
pageSlug = section.dataset.slug;
|
|
||||||
|
|
||||||
wireCommentForm();
|
|
||||||
loadComments();
|
|
||||||
}
|
|
||||||
|
|
||||||
initComments();
|
|
||||||
|
|||||||
@@ -1,240 +1,7 @@
|
|||||||
const STATES = [
|
(function () {
|
||||||
{ key: "completed", label: "Completed" },
|
"use strict";
|
||||||
{ key: "manager_review", label: "Manager Review" },
|
var script = document.createElement("script");
|
||||||
{ key: "in_progress", label: "In Progress" },
|
script.src = "/assets/scripts/pages/competency-status-board.js";
|
||||||
{ key: "not_started", label: "Not Started" },
|
script.defer = true;
|
||||||
{ key: "comments", label: "Comments" },
|
document.head.appendChild(script);
|
||||||
];
|
}());
|
||||||
|
|
||||||
const LEVELS = [
|
|
||||||
{ key: "graduate", label: "Graduate" },
|
|
||||||
{ key: "engineer", label: "Engineer" },
|
|
||||||
];
|
|
||||||
|
|
||||||
let currentLevel = LEVELS[0].key;
|
|
||||||
|
|
||||||
function isMobile() {
|
|
||||||
return window.matchMedia("(max-width: 600px)").matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderLevelTabs() {
|
|
||||||
const board = document.getElementById("kanban-board");
|
|
||||||
if (!board || document.getElementById("level-tabs")) return;
|
|
||||||
|
|
||||||
const nav = document.createElement("div");
|
|
||||||
nav.id = "level-tabs";
|
|
||||||
|
|
||||||
LEVELS.forEach(({ key, label }) => {
|
|
||||||
const btn = document.createElement("button");
|
|
||||||
btn.textContent = label;
|
|
||||||
btn.dataset.level = key;
|
|
||||||
btn.className = "level-tab" + (key === currentLevel ? " active" : "");
|
|
||||||
btn.addEventListener("click", () => {
|
|
||||||
currentLevel = key;
|
|
||||||
document.querySelectorAll(".level-tab").forEach(b =>
|
|
||||||
b.classList.toggle("active", b.dataset.level === key)
|
|
||||||
);
|
|
||||||
loadBoard();
|
|
||||||
});
|
|
||||||
nav.appendChild(btn);
|
|
||||||
});
|
|
||||||
|
|
||||||
board.insertAdjacentElement("beforebegin", nav);
|
|
||||||
|
|
||||||
if (!document.getElementById("level-tab-styles")) {
|
|
||||||
const style = document.createElement("style");
|
|
||||||
style.id = "level-tab-styles";
|
|
||||||
style.textContent = `
|
|
||||||
#level-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5em;
|
|
||||||
margin-bottom: 1em;
|
|
||||||
}
|
|
||||||
.level-tab {
|
|
||||||
padding: 0.4em 1.2em;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
background: none;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.95em;
|
|
||||||
}
|
|
||||||
.level-tab.active {
|
|
||||||
background: #333;
|
|
||||||
color: #fff;
|
|
||||||
border-color: #333;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function populateMobileControls(items) {
|
|
||||||
if (!isMobile()) return;
|
|
||||||
|
|
||||||
const panel = document.getElementById("mobile-move-panel");
|
|
||||||
const itemSelect = document.getElementById("move-item");
|
|
||||||
const fromSelect = document.getElementById("move-from");
|
|
||||||
const toSelect = document.getElementById("move-to");
|
|
||||||
|
|
||||||
itemSelect.innerHTML = "<option value=''>Select competency</option>";
|
|
||||||
fromSelect.innerHTML = "<option value=''>From</option>";
|
|
||||||
toSelect.innerHTML = "<option value=''>To</option>";
|
|
||||||
|
|
||||||
items.forEach((item) => {
|
|
||||||
const opt = document.createElement("option");
|
|
||||||
opt.value = item.id;
|
|
||||||
opt.textContent = item.title;
|
|
||||||
opt.dataset.state = item.state;
|
|
||||||
itemSelect.appendChild(opt);
|
|
||||||
});
|
|
||||||
|
|
||||||
STATES.forEach((s) => {
|
|
||||||
fromSelect.appendChild(new Option(s.label, s.key));
|
|
||||||
toSelect.appendChild(new Option(s.label, s.key));
|
|
||||||
});
|
|
||||||
|
|
||||||
itemSelect.onchange = () => {
|
|
||||||
const selected = itemSelect.selectedOptions[0];
|
|
||||||
if (selected?.dataset.state) {
|
|
||||||
fromSelect.value = selected.dataset.state;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const moveConfirmBtn = document.getElementById("move-confirm");
|
|
||||||
if (moveConfirmBtn) {
|
|
||||||
moveConfirmBtn.addEventListener("click", async () => {
|
|
||||||
const itemId = document.getElementById("move-item").value;
|
|
||||||
const from = document.getElementById("move-from").value;
|
|
||||||
const to = document.getElementById("move-to").value;
|
|
||||||
|
|
||||||
if (!itemId || !to) {
|
|
||||||
alert("Select an item and target column");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (from === to) {
|
|
||||||
alert("Item is already in that column");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(`/api/competencies/items/${itemId}/state`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ state: to }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
loadBoard();
|
|
||||||
} else {
|
|
||||||
alert("Failed to move competency");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let draggedItemId = null;
|
|
||||||
|
|
||||||
function countByState(items) {
|
|
||||||
return items.reduce((acc, item) => {
|
|
||||||
acc[item.state] = (acc[item.state] || 0) + 1;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateProgress(items) {
|
|
||||||
const total = items.length;
|
|
||||||
const completed = items.filter(i => i.state === "completed").length;
|
|
||||||
const percent = total === 0 ? 0 : Math.round((completed / total) * 100);
|
|
||||||
|
|
||||||
const fill = document.getElementById("progress-fill");
|
|
||||||
const label = document.getElementById("progress-label");
|
|
||||||
|
|
||||||
if (fill) fill.style.width = `${percent}%`;
|
|
||||||
if (label) label.textContent = `${percent}% completed`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createCard(item) {
|
|
||||||
const card = document.createElement("div");
|
|
||||||
card.className = "kanban-card";
|
|
||||||
card.draggable = true;
|
|
||||||
card.dataset.id = item.id;
|
|
||||||
card.textContent = item.title;
|
|
||||||
|
|
||||||
card.addEventListener("dragstart", () => {
|
|
||||||
draggedItemId = item.id;
|
|
||||||
card.classList.add("dragging");
|
|
||||||
});
|
|
||||||
|
|
||||||
card.addEventListener("dragend", () => {
|
|
||||||
draggedItemId = null;
|
|
||||||
card.classList.remove("dragging");
|
|
||||||
});
|
|
||||||
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createColumn(state, items, counts) {
|
|
||||||
const col = document.createElement("div");
|
|
||||||
col.className = "kanban-column";
|
|
||||||
col.dataset.state = state.key;
|
|
||||||
|
|
||||||
const header = document.createElement("h3");
|
|
||||||
header.innerHTML = `
|
|
||||||
<span class="kanban-title">${state.label}</span>
|
|
||||||
<span class="kanban-count">${counts[state.key] || 0}</span>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const list = document.createElement("div");
|
|
||||||
list.className = "kanban-list";
|
|
||||||
|
|
||||||
list.addEventListener("dragover", (e) => e.preventDefault());
|
|
||||||
|
|
||||||
list.addEventListener("drop", async () => {
|
|
||||||
if (!draggedItemId) return;
|
|
||||||
|
|
||||||
const res = await fetch(`/api/competencies/items/${draggedItemId}/state`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ state: state.key }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
loadBoard();
|
|
||||||
} else {
|
|
||||||
alert("Failed to update state");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
items
|
|
||||||
.filter((i) => i.state === state.key)
|
|
||||||
.forEach((i) => list.appendChild(createCard(i)));
|
|
||||||
|
|
||||||
col.appendChild(header);
|
|
||||||
col.appendChild(list);
|
|
||||||
|
|
||||||
return col;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderBoard(items) {
|
|
||||||
const board = document.getElementById("kanban-board");
|
|
||||||
if (!board) return;
|
|
||||||
board.innerHTML = "";
|
|
||||||
|
|
||||||
const counts = countByState(items);
|
|
||||||
|
|
||||||
STATES.forEach((state) => {
|
|
||||||
board.appendChild(createColumn(state, items, counts));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadBoard() {
|
|
||||||
const res = await fetch(`/api/competencies/items?group=${currentLevel}`);
|
|
||||||
const items = await res.json();
|
|
||||||
|
|
||||||
updateProgress(items);
|
|
||||||
renderBoard(items);
|
|
||||||
populateMobileControls(items);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderLevelTabs();
|
|
||||||
loadBoard();
|
|
||||||
|
|||||||
196
assets/scripts/features/comments.js
Executable file
@@ -0,0 +1,196 @@
|
|||||||
|
let pageSlug = null;
|
||||||
|
|
||||||
|
/* -----------------------------
|
||||||
|
* DOM builders
|
||||||
|
* ----------------------------- */
|
||||||
|
|
||||||
|
|
||||||
|
function showReplyForm(container, parentId) {
|
||||||
|
if (container.querySelector(".reply-form")) return;
|
||||||
|
|
||||||
|
const form = document.createElement("form");
|
||||||
|
form.className = "reply-form";
|
||||||
|
|
||||||
|
form.innerHTML = `
|
||||||
|
<input type="text" name="author" placeholder="Your name (optional)">
|
||||||
|
<textarea name="content" required placeholder="Write a reply..."></textarea>
|
||||||
|
<button type="submit">Post reply</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
form.addEventListener("submit", async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const author = form.author.value.trim();
|
||||||
|
const content = form.content.value.trim();
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
const ok = await postComment(author, content, parentId);
|
||||||
|
if (ok) loadComments();
|
||||||
|
else alert("Failed to post reply");
|
||||||
|
});
|
||||||
|
|
||||||
|
container.appendChild(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createComment(comment) {
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.className = "comment";
|
||||||
|
wrapper.dataset.id = comment.id;
|
||||||
|
wrapper.id = `comment-${comment.id}`;
|
||||||
|
|
||||||
|
const author = document.createElement("strong");
|
||||||
|
author.textContent = comment.author || "Anonymous";
|
||||||
|
|
||||||
|
const body = document.createElement("p");
|
||||||
|
body.textContent = comment.content;
|
||||||
|
|
||||||
|
const time = document.createElement("time");
|
||||||
|
time.className = "comment-date";
|
||||||
|
time.dateTime = comment.created_at;
|
||||||
|
time.textContent = new Date(comment.created_at).toLocaleString();
|
||||||
|
|
||||||
|
const replyBtn = document.createElement("button");
|
||||||
|
replyBtn.className = "comment-reply";
|
||||||
|
replyBtn.textContent = "Reply";
|
||||||
|
replyBtn.onclick = () =>
|
||||||
|
showReplyForm(wrapper, Number(comment.id));
|
||||||
|
|
||||||
|
wrapper.append(author, body, time, replyBtn);
|
||||||
|
|
||||||
|
if (comment.children?.length) {
|
||||||
|
const replies = document.createElement("div");
|
||||||
|
replies.className = "comment-children";
|
||||||
|
|
||||||
|
comment.children.forEach(child => {
|
||||||
|
replies.appendChild(createComment(child));
|
||||||
|
});
|
||||||
|
|
||||||
|
wrapper.appendChild(replies);
|
||||||
|
}
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function buildCommentTree(comments) {
|
||||||
|
const byId = {};
|
||||||
|
const roots = [];
|
||||||
|
|
||||||
|
comments.forEach(comment => {
|
||||||
|
comment.children = [];
|
||||||
|
byId[comment.id] = comment;
|
||||||
|
});
|
||||||
|
|
||||||
|
comments.forEach(comment => {
|
||||||
|
if (comment.parent_id) {
|
||||||
|
const parent = byId[comment.parent_id];
|
||||||
|
if (parent) parent.children.push(comment);
|
||||||
|
} else {
|
||||||
|
roots.push(comment);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* -----------------------------
|
||||||
|
* Rendering
|
||||||
|
* ----------------------------- */
|
||||||
|
|
||||||
|
function renderComments(comments) {
|
||||||
|
const list = document.getElementById("comments-list");
|
||||||
|
list.innerHTML = "";
|
||||||
|
|
||||||
|
if (!comments.length) {
|
||||||
|
const empty = document.createElement("p");
|
||||||
|
empty.className = "comments-empty";
|
||||||
|
empty.textContent = "No comments yet.";
|
||||||
|
list.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tree = buildCommentTree(comments);
|
||||||
|
tree.forEach(comment => list.appendChild(createComment(comment)));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* -----------------------------
|
||||||
|
* API
|
||||||
|
* ----------------------------- */
|
||||||
|
|
||||||
|
async function fetchComments() {
|
||||||
|
const res = await fetch(`/api/comments/${pageSlug}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error("Failed to fetch comments");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postComment(author, content, parentId = null) {
|
||||||
|
const res = await fetch("/api/comments", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
page_slug: pageSlug,
|
||||||
|
author: author || null,
|
||||||
|
content,
|
||||||
|
parent_id: parentId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* -----------------------------
|
||||||
|
* Form handling
|
||||||
|
* ----------------------------- */
|
||||||
|
|
||||||
|
function wireCommentForm() {
|
||||||
|
const form = document.getElementById("comment-form");
|
||||||
|
|
||||||
|
form.addEventListener("submit", async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const authorInput = form.querySelector("input[name='author']");
|
||||||
|
const textarea = form.querySelector("textarea[name='content']");
|
||||||
|
|
||||||
|
const author = authorInput.value.trim();
|
||||||
|
const content = textarea.value.trim();
|
||||||
|
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
const ok = await postComment(author, content);
|
||||||
|
if (ok) {
|
||||||
|
textarea.value = "";
|
||||||
|
loadComments();
|
||||||
|
} else {
|
||||||
|
alert("Failed to post comment");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* -----------------------------
|
||||||
|
* Entry point
|
||||||
|
* ----------------------------- */
|
||||||
|
|
||||||
|
async function loadComments() {
|
||||||
|
const comments = await fetchComments();
|
||||||
|
renderComments(comments);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initComments() {
|
||||||
|
const section = document.getElementById("comments");
|
||||||
|
if (!section) return;
|
||||||
|
|
||||||
|
pageSlug = section.dataset.slug;
|
||||||
|
|
||||||
|
wireCommentForm();
|
||||||
|
loadComments();
|
||||||
|
}
|
||||||
|
|
||||||
|
initComments();
|
||||||
655
assets/scripts/features/hidden-details.js
Executable file
@@ -0,0 +1,655 @@
|
|||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Hidden site details
|
||||||
|
* -------------------
|
||||||
|
* This file adds small hidden interactions across the site: footer notes,
|
||||||
|
* click targets, search/keyboard secrets, rare messages, and page-specific
|
||||||
|
* behavior for the hidden /play pages.
|
||||||
|
*
|
||||||
|
* How to add your own things:
|
||||||
|
* - Add new text to EDITABLE CONTENT arrays such as `details`, `poems`,
|
||||||
|
* `greetings`, or `lore`.
|
||||||
|
* - Add search-triggered toast messages to `SEARCH_TOASTS`.
|
||||||
|
* - Add search-triggered page redirects to `SEARCH_ROUTES`.
|
||||||
|
* - Add typed keyboard phrases to `KEYBOARD_SECRETS` or `LONG_KEYBOARD_SECRETS`.
|
||||||
|
* - Add a new feature by writing an `addYourFeature(memory)` function and
|
||||||
|
* adding it to the `FEATURES` list at the bottom of the file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Storage keys. v1 is read once so old visitors keep their hidden progress.
|
||||||
|
const STORAGE_KEY = "zxh_hidden_details_v2";
|
||||||
|
const OLD_STORAGE_KEY = "zxh_hidden_details_v1";
|
||||||
|
|
||||||
|
// Shared page context. These are captured once so daily random picks stay stable.
|
||||||
|
const now = new Date();
|
||||||
|
const hour = now.getHours();
|
||||||
|
const path = window.location.pathname;
|
||||||
|
const state = readState();
|
||||||
|
// -----------------------------
|
||||||
|
// EDITABLE CONTENT
|
||||||
|
// -----------------------------
|
||||||
|
// Generated by the hidden narrative authoring page.
|
||||||
|
// Friendly source of truth: assets/content/hidden-details.json
|
||||||
|
|
||||||
|
const familyLayers = [];
|
||||||
|
|
||||||
|
const details = [
|
||||||
|
"Keep one small appointment with yourself today.",
|
||||||
|
"Leave one kindness prepared for tomorrow-you.",
|
||||||
|
"Discipline can speak in a calm voice.",
|
||||||
|
"When the task feels heavy, remember the life it is trying to make possible.",
|
||||||
|
"Pause here: you are allowed to be proud of the person who kept returning."
|
||||||
|
];
|
||||||
|
|
||||||
|
const poems = [];
|
||||||
|
|
||||||
|
const greetings = [];
|
||||||
|
|
||||||
|
const nightMessages = [];
|
||||||
|
|
||||||
|
const lore = {
|
||||||
|
"quotes": [
|
||||||
|
"14/07/26 - big demo meeting with CEO tomorrow !!",
|
||||||
|
"Readiness often arrives ten minutes after you begin.",
|
||||||
|
"Rest is not a reward for becoming exhausted. It is maintenance for the person doing the becoming.",
|
||||||
|
"Before the world starts making requests, give your own life a few undivided minutes.",
|
||||||
|
"Every meaningful yes needs a few well-placed noes around it.",
|
||||||
|
"Motivation is weather. Build a shelter sturdy enough to keep going when the sky changes.",
|
||||||
|
"Do not design only for your best days. Create a minimum version of the habit that even a tired day can hold.",
|
||||||
|
"No repetition is wasted just because nobody applauded it.",
|
||||||
|
"Look backwards long enough to notice the ground you have already crossed.",
|
||||||
|
"Use the voice that helps you stand up again.",
|
||||||
|
"Sometimes courage is simply opening the document again.",
|
||||||
|
"When resistance feels enormous, promise only five minutes. The aim is not to finish; it is to make starting feel safe again.",
|
||||||
|
"If the habit keeps failing, redesign the doorway: put the book on the pillow, the shoes by the door, the difficult task at the hour your mind is clearest.",
|
||||||
|
"The exciting beginning and the visible result are connected by ordinary days. Learn to respect the bridge.",
|
||||||
|
"Completion creates its own kind of energy. Close one tab, send one message, clear one surface, finish one small loop."
|
||||||
|
],
|
||||||
|
"conversations": [],
|
||||||
|
"journals": [
|
||||||
|
"10/07/26 - surviving a heatwave whilst working :-)",
|
||||||
|
"You do not need the whole staircase today. Name the next honest step, make it small enough to begin, and let motion teach you what planning cannot.",
|
||||||
|
"Replace ‘I am lazy’ with evidence: what drained me, what helped me, and what is the smallest change I can test tomorrow? Curiosity builds what shame only bruises.",
|
||||||
|
"A missed day is data, not destiny. Return before the story in your head turns one pause into an identity."
|
||||||
|
],
|
||||||
|
"warnings": [],
|
||||||
|
"dreams": [
|
||||||
|
"The path was hidden, but the compass still answered: values before mood, direction before speed, one careful footfall before certainty.",
|
||||||
|
"Long after inspiration went home, a single workshop light stayed on. Inside, patience was sanding rough effort into skill.",
|
||||||
|
"For a long time the seed had nothing visible to show. It was still becoming roots."
|
||||||
|
],
|
||||||
|
"cassettes": [],
|
||||||
|
"fakeUsers": [],
|
||||||
|
"seasonal": {},
|
||||||
|
"homepageTakeovers": [],
|
||||||
|
"roomLinks": []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Exact search text -> toast message.
|
||||||
|
const SEARCH_TOASTS = {};
|
||||||
|
|
||||||
|
// Exact search text -> hidden page route.
|
||||||
|
const SEARCH_ROUTES = {};
|
||||||
|
|
||||||
|
// Short typed phrases. Stored in sessionStorage as a rolling key chain.
|
||||||
|
const KEYBOARD_SECRETS = [];
|
||||||
|
|
||||||
|
// Longer typed phrases and character names.
|
||||||
|
const LONG_KEYBOARD_SECRETS = [];
|
||||||
|
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// STATE HELPERS
|
||||||
|
// -----------------------------
|
||||||
|
|
||||||
|
function readState() {
|
||||||
|
let current = {};
|
||||||
|
try {
|
||||||
|
current = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
|
||||||
|
} catch (_) {}
|
||||||
|
if (!current.visits) {
|
||||||
|
try {
|
||||||
|
const oldState = JSON.parse(localStorage.getItem(OLD_STORAGE_KEY) || "{}");
|
||||||
|
current = { ...oldState, migratedFromV1: true };
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(current));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeState(next) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(list, salt) {
|
||||||
|
const seed = hash(`${path}|${now.toDateString()}|${salt || ""}`);
|
||||||
|
return list[seed % list.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hash(value) {
|
||||||
|
let h = 2166136261;
|
||||||
|
for (let i = 0; i < value.length; i += 1) {
|
||||||
|
h ^= value.charCodeAt(i);
|
||||||
|
h = Math.imul(h, 16777619);
|
||||||
|
}
|
||||||
|
return Math.abs(h >>> 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// UI HELPERS
|
||||||
|
// -----------------------------
|
||||||
|
|
||||||
|
function hiddenMessageMarkup(message) {
|
||||||
|
return `<span class="hidden-message-text">${escapeHtml(message)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHiddenMessage(el, message) {
|
||||||
|
delete el.dataset.hiddenCharacter;
|
||||||
|
el.innerHTML = hiddenMessageMarkup(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(message, duration) {
|
||||||
|
let el = document.querySelector(".hidden-toast");
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement("div");
|
||||||
|
el.className = "hidden-toast";
|
||||||
|
el.setAttribute("role", "status");
|
||||||
|
document.body.appendChild(el);
|
||||||
|
}
|
||||||
|
setHiddenMessage(el, message);
|
||||||
|
el.classList.add("is-visible");
|
||||||
|
window.clearTimeout(el._timer);
|
||||||
|
el._timer = window.setTimeout(() => el.classList.remove("is-visible"), duration || 5600);
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectTo(route) {
|
||||||
|
window.location.href = route;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSecretAction(secret) {
|
||||||
|
if (secret.route) redirectTo(secret.route);
|
||||||
|
if (secret.message) toast(secret.message);
|
||||||
|
if (secret.song) playTinySong();
|
||||||
|
}
|
||||||
|
|
||||||
|
function layerForSearch(value) {
|
||||||
|
if (value.includes("future")) return 4;
|
||||||
|
if (value.includes("sensei") || value.includes("aphy")) return 3;
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberVisit() {
|
||||||
|
const visits = (state.visits || 0) + 1;
|
||||||
|
const seen = Array.isArray(state.seen) ? state.seen : [];
|
||||||
|
const pathCounts = { ...(state.pathCounts || {}) };
|
||||||
|
pathCounts[path] = (pathCounts[path] || 0) + 1;
|
||||||
|
const layerVisits = { ...(state.layerVisits || {}) };
|
||||||
|
const next = {
|
||||||
|
...state,
|
||||||
|
visits,
|
||||||
|
lastPath: path,
|
||||||
|
pathCounts,
|
||||||
|
layerVisits,
|
||||||
|
seen: Array.from(new Set([...seen, path])).slice(-100),
|
||||||
|
firstSeen: state.firstSeen || new Date().toISOString(),
|
||||||
|
lastSeen: new Date().toISOString()
|
||||||
|
};
|
||||||
|
writeState(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function awardLayer(layer, reason) {
|
||||||
|
const next = readState();
|
||||||
|
next.layerVisits = { ...(next.layerVisits || {}) };
|
||||||
|
next.layerVisits[layer] = (next.layerVisits[layer] || 0) + 1;
|
||||||
|
next.lastLayerReason = reason;
|
||||||
|
writeState(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// GLOBAL FEATURES
|
||||||
|
// -----------------------------
|
||||||
|
|
||||||
|
function addFooterNote(memory) {
|
||||||
|
const footer = document.querySelector("footer");
|
||||||
|
if (!footer || !details.length) return;
|
||||||
|
const note = document.createElement("p");
|
||||||
|
note.className = "hidden-footer-note";
|
||||||
|
const base = pick(details, "footer");
|
||||||
|
setHiddenMessage(note, memory.visits > 4 ? `${base} You have passed through ${memory.visits} times.` : base);
|
||||||
|
footer.appendChild(note);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addHomepageGreeting(memory) {
|
||||||
|
const target = document.querySelector("#db-greeting");
|
||||||
|
if (!target || !greetings.length) return;
|
||||||
|
setHiddenMessage(target, pick(greetings, `greeting-${memory.visits}`));
|
||||||
|
const extra = document.createElement("p");
|
||||||
|
extra.className = "hidden-greeting";
|
||||||
|
setHiddenMessage(extra, "");
|
||||||
|
target.closest("div")?.appendChild(extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addWhispers() {
|
||||||
|
// Event: click one of the tiny "?" marks appended to long paragraphs.
|
||||||
|
const paragraphs = Array.from(document.querySelectorAll("main p, #content p, article p")).filter((p) => p.textContent.trim().length > 80);
|
||||||
|
paragraphs.slice(0, 5).forEach((p, index) => {
|
||||||
|
if ((hash(path + index) + index) % 3 !== 0) return;
|
||||||
|
const mark = document.createElement("span");
|
||||||
|
mark.className = "hidden-whisper";
|
||||||
|
mark.tabIndex = 0;
|
||||||
|
mark.textContent = " ?";
|
||||||
|
const source = index % 2 ? poems : details;
|
||||||
|
if (!source.length) return;
|
||||||
|
mark.title = pick(source, `whisper-${index}`);
|
||||||
|
mark.addEventListener("click", () => {
|
||||||
|
awardLayer(index % 2 ? 2 : 1, "paragraph whisper");
|
||||||
|
toast(mark.title);
|
||||||
|
});
|
||||||
|
p.appendChild(mark);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCornerObject() {
|
||||||
|
// Event: click the small fixed button in the bottom-left corner.
|
||||||
|
const object = document.createElement("button");
|
||||||
|
object.className = "hidden-corner-object";
|
||||||
|
object.type = "button";
|
||||||
|
object.title = "hidden interaction";
|
||||||
|
object.textContent = state.visits % 2 ? "*" : "~";
|
||||||
|
document.body.appendChild(object);
|
||||||
|
let clicks = 0;
|
||||||
|
object.addEventListener("click", () => {
|
||||||
|
clicks += 1;
|
||||||
|
awardLayer(clicks > 3 ? 3 : 1, "corner object");
|
||||||
|
toast("Hidden interaction recorded.");
|
||||||
|
if (clicks === 7) window.location.href = "/play/left-behind.html";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLogoSearchAndKeyboardSecrets(memory) {
|
||||||
|
// Events:
|
||||||
|
// - Click `.site-brand` repeatedly on the homepage.
|
||||||
|
// - Type exact words into `#search-input`.
|
||||||
|
// - Type phrases anywhere on the page.
|
||||||
|
const nav = document.querySelector(".site-brand");
|
||||||
|
if (nav) {
|
||||||
|
let taps = Number(sessionStorage.getItem("zxh_logo_taps") || 0);
|
||||||
|
nav.addEventListener("click", (event) => {
|
||||||
|
if (path === "/" || path === "/index.html") {
|
||||||
|
event.preventDefault();
|
||||||
|
taps += 1;
|
||||||
|
sessionStorage.setItem("zxh_logo_taps", String(taps));
|
||||||
|
awardLayer(taps >= 5 ? 3 : 1, "logo taps");
|
||||||
|
toast(taps >= 5 ? "Hidden route available: /play/dream-corridor.html" : "Hidden interaction recorded.");
|
||||||
|
if (taps >= 8) window.location.href = "/play/dream-corridor.html";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const search = document.querySelector("#search-input");
|
||||||
|
if (search) {
|
||||||
|
search.addEventListener("input", () => {
|
||||||
|
const value = search.value.trim().toLowerCase();
|
||||||
|
if (SEARCH_TOASTS[value]) toast(SEARCH_TOASTS[value]);
|
||||||
|
if (SEARCH_ROUTES[value]) {
|
||||||
|
awardLayer(layerForSearch(value), `search ${value}`);
|
||||||
|
redirectTo(SEARCH_ROUTES[value]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
const chain = `${sessionStorage.getItem("zxh_key_chain") || ""}${key}`.slice(-18);
|
||||||
|
sessionStorage.setItem("zxh_key_chain", chain);
|
||||||
|
KEYBOARD_SECRETS
|
||||||
|
.filter((secret) => chain.endsWith(secret.phrase))
|
||||||
|
.forEach(runSecretAction);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (memory.seen?.length >= 6 && !state.travellerNoteShown) {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
toast("Hidden path recorded.");
|
||||||
|
writeState({ ...readState(), travellerNoteShown: true });
|
||||||
|
}, 1600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRareEvents() {
|
||||||
|
if (hour >= 22 || hour < 5) {
|
||||||
|
document.body.classList.add("hidden-late-night");
|
||||||
|
if (nightMessages.length) window.setTimeout(() => toast(pick(nightMessages, "night"), 2200), 900);
|
||||||
|
}
|
||||||
|
const roll = Math.random();
|
||||||
|
if (roll < 0.003) {
|
||||||
|
window.setTimeout(() => showDriftNote("", ""), 1800);
|
||||||
|
awardLayer(3, "rare sensei appearance");
|
||||||
|
} else if (roll < 0.008) {
|
||||||
|
window.setTimeout(() => toast("Hidden warning."), 2400);
|
||||||
|
awardLayer(4, "future warning");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDriftNote(text, art) {
|
||||||
|
const panel = document.createElement("aside");
|
||||||
|
panel.className = "hidden-drift-note";
|
||||||
|
panel.innerHTML = `<div class="hidden-drift-message">${hiddenMessageMarkup(text)}</div><pre>${escapeHtml(art)}</pre><button type="button">Close</button>`;
|
||||||
|
panel.querySelector("button").addEventListener("click", () => panel.remove());
|
||||||
|
document.body.appendChild(panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSecretLinks() {
|
||||||
|
[
|
||||||
|
["/play/left-behind.html", "left behind"],
|
||||||
|
["/play/dream-corridor.html", "dream corridor"],
|
||||||
|
["/play/kitchen-light.html", "kitchen light"],
|
||||||
|
...lore.roomLinks
|
||||||
|
].forEach(([href, label], index) => {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.className = "hidden-secret-link";
|
||||||
|
link.href = href;
|
||||||
|
link.textContent = label;
|
||||||
|
link.style.left = `${index + 1}px`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function enhanceErrors() {
|
||||||
|
if (document.title.match(/404|not found/i) || document.body.textContent.match(/404|not found/i)) {
|
||||||
|
toast("Page not found.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addContinuity(memory) {
|
||||||
|
const milestones = {
|
||||||
|
64: "Hidden layer unlocked."
|
||||||
|
};
|
||||||
|
if (milestones[memory.visits] && !state[`milestone_${memory.visits}`]) {
|
||||||
|
const layer = memory.visits >= 31 ? 4 : memory.visits >= 9 ? 3 : 2;
|
||||||
|
awardLayer(layer, `visit milestone ${memory.visits}`);
|
||||||
|
window.setTimeout(() => toast(milestones[memory.visits], 7000), 1200);
|
||||||
|
writeState({ ...readState(), [`milestone_${memory.visits}`]: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = memory.pathCounts?.[path] || 0;
|
||||||
|
if ([3, 7, 12].includes(count)) {
|
||||||
|
window.setTimeout(() => toast("Page revisit recorded.", 6400), 1700);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSeasonAndDates(memory) {
|
||||||
|
const month = now.getMonth();
|
||||||
|
const day = now.getDate();
|
||||||
|
const season = month < 2 || month === 11 ? "winter" : month < 5 ? "spring" : month < 8 ? "summer" : "autumn";
|
||||||
|
if (Math.random() < 0.18 && lore.seasonal[season]) window.setTimeout(() => toast(lore.seasonal[season], 5600), 2600);
|
||||||
|
const first = memory.firstSeen ? new Date(memory.firstSeen) : null;
|
||||||
|
if (first && memory.visits > 1 && first.getMonth() === month && first.getDate() === day) {
|
||||||
|
window.setTimeout(() => toast("Visitor anniversary recorded.", 7000), 1400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addObjectConstellation() {
|
||||||
|
// Event: click the small keepsake buttons along the bottom rail.
|
||||||
|
const rail = document.createElement("div");
|
||||||
|
rail.className = "hidden-object-rail";
|
||||||
|
rail.setAttribute("aria-label", "Hidden objects");
|
||||||
|
const objects = [
|
||||||
|
["ring", "Hidden object recorded."],
|
||||||
|
["bot", "Hidden object recorded."],
|
||||||
|
["tea", "Hidden object recorded."],
|
||||||
|
["crayon", "Hidden object recorded."],
|
||||||
|
["clock", "Hidden object recorded."]
|
||||||
|
];
|
||||||
|
objects.forEach(([name, message]) => {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = `hidden-keepsake hidden-keepsake--${name}`;
|
||||||
|
button.title = name;
|
||||||
|
button.textContent = { ring: "o", bot: "#", tea: "u", crayon: "/", clock: ":" }[name];
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const next = readState();
|
||||||
|
next.keepsakes = Array.from(new Set([...(next.keepsakes || []), name]));
|
||||||
|
writeState(next);
|
||||||
|
awardLayer(next.keepsakes.length >= 3 ? 3 : 2, `keepsake ${name}`);
|
||||||
|
toast(message);
|
||||||
|
if (next.keepsakes.length >= objects.length) {
|
||||||
|
window.setTimeout(() => toast("Hidden object set completed."), 1100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
rail.appendChild(button);
|
||||||
|
});
|
||||||
|
document.body.appendChild(rail);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addWeatherWindow(memory) {
|
||||||
|
// Event: click the small "window" button. This asks for browser geolocation.
|
||||||
|
if (memory.visits < 3 || !("geolocation" in navigator)) return;
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.className = "hidden-weather-window";
|
||||||
|
button.type = "button";
|
||||||
|
button.title = "Check outside weather";
|
||||||
|
button.textContent = "window";
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
toast("Checking outside weather.");
|
||||||
|
navigator.geolocation.getCurrentPosition((pos) => {
|
||||||
|
const { latitude, longitude } = pos.coords;
|
||||||
|
fetch(`https://api.open-meteo.com/v1/forecast?latitude=${latitude.toFixed(3)}&longitude=${longitude.toFixed(3)}¤t=temperature_2m,precipitation&timezone=auto`)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
const current = data.current || {};
|
||||||
|
const rain = Number(current.precipitation || 0) > 0;
|
||||||
|
const temp = Math.round(Number(current.temperature_2m));
|
||||||
|
toast(rain ? `Outside: rain, ${temp}C.` : `Outside: ${temp}C.`);
|
||||||
|
})
|
||||||
|
.catch(() => toast("Weather check failed."));
|
||||||
|
}, () => toast("No outside weather today."));
|
||||||
|
});
|
||||||
|
document.body.appendChild(button);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOldWebLayer(memory) {
|
||||||
|
if (Math.random() < 0.08 || memory.visits > 10) {
|
||||||
|
const stamp = document.createElement("div");
|
||||||
|
stamp.className = "hidden-oldweb-stamp";
|
||||||
|
if (!lore.fakeUsers.length) return;
|
||||||
|
stamp.title = pick(lore.fakeUsers, "fake-user");
|
||||||
|
stamp.textContent = "best viewed with patience";
|
||||||
|
stamp.addEventListener("click", () => {
|
||||||
|
awardLayer(1, "old web stamp");
|
||||||
|
toast(stamp.title);
|
||||||
|
});
|
||||||
|
document.body.appendChild(stamp);
|
||||||
|
}
|
||||||
|
const comments = document.querySelector("#comments");
|
||||||
|
if (comments && lore.fakeUsers.length && !comments.querySelector(".hidden-guestbook-line")) {
|
||||||
|
const line = document.createElement("p");
|
||||||
|
line.className = "hidden-guestbook-line";
|
||||||
|
setHiddenMessage(line, pick(lore.fakeUsers, "comment-lore"));
|
||||||
|
comments.appendChild(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSourceRelics() {
|
||||||
|
const marker = document.createComment("hidden-details");
|
||||||
|
document.documentElement.appendChild(marker);
|
||||||
|
document.querySelectorAll("img[alt=''], img:not([alt])").forEach((img, index) => {
|
||||||
|
if (index > 2) return;
|
||||||
|
img.alt = "Decorative image.";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLongKeyboardSecrets() {
|
||||||
|
// Event: type longer hidden phrases anywhere on the page.
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
const chain = `${sessionStorage.getItem("zxh_second_chain") || ""}${event.key.toLowerCase()}`.slice(-24);
|
||||||
|
sessionStorage.setItem("zxh_second_chain", chain);
|
||||||
|
LONG_KEYBOARD_SECRETS
|
||||||
|
.filter((secret) => chain.endsWith(secret.phrase))
|
||||||
|
.forEach(runSecretAction);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPatienceRewards(memory) {
|
||||||
|
// Event: no movement, key presses, scrolling, or clicking for 90 seconds.
|
||||||
|
if (memory.visits < 2) return;
|
||||||
|
let idleTimer = null;
|
||||||
|
const startIdle = () => {
|
||||||
|
window.clearTimeout(idleTimer);
|
||||||
|
idleTimer = window.setTimeout(() => {
|
||||||
|
awardLayer(5, "patience");
|
||||||
|
toast("Idle moment recorded.", 7600);
|
||||||
|
const next = readState();
|
||||||
|
next.patientMoments = (next.patientMoments || 0) + 1;
|
||||||
|
writeState(next);
|
||||||
|
}, 90000);
|
||||||
|
};
|
||||||
|
["mousemove", "keydown", "scroll", "click"].forEach((name) => document.addEventListener(name, startIdle, { passive: true }));
|
||||||
|
startIdle();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRareSecondLayer() {
|
||||||
|
const roll = Math.random();
|
||||||
|
if ((path === "/" || path === "/index.html") && roll < 0.006) {
|
||||||
|
document.body.classList.add("hidden-home-takeover");
|
||||||
|
if (lore.homepageTakeovers.length) window.setTimeout(() => showDriftNote(pick(lore.homepageTakeovers, "takeover"), ""), 600);
|
||||||
|
} else if (roll < 0.002) {
|
||||||
|
window.setTimeout(() => showDriftNote("", ""), 1500);
|
||||||
|
} else if (roll < 0.006) {
|
||||||
|
if (lore.dreams.length) window.setTimeout(() => toast(pick(lore.dreams, "rare-dream"), 7600), 2000);
|
||||||
|
} else if (roll < 0.014) {
|
||||||
|
if (lore.warnings.length) window.setTimeout(() => toast(pick(lore.warnings, "rare-warning"), 6800), 2300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFamilyLayerIndex(memory) {
|
||||||
|
if (!path.includes("/play/family-layer-index")) return;
|
||||||
|
const target = document.querySelector("[data-family-layer-index]");
|
||||||
|
if (!target) return;
|
||||||
|
const visits = readState().layerVisits || {};
|
||||||
|
target.innerHTML = familyLayers.map((line, layer) => {
|
||||||
|
const count = visits[layer] || 0;
|
||||||
|
return `<li class="hidden-layer-message"><strong>${hiddenMessageMarkup(line)}</strong><br><span>local encounters: ${count}</span></li>`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPageSpecificSecrets() {
|
||||||
|
// Events only used by specific `/play/...` pages.
|
||||||
|
if (path.includes("/play/cassette-log")) {
|
||||||
|
document.querySelectorAll("[data-cassette]").forEach((button, index) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
awardLayer(index >= 2 ? 4 : 2, "voice note");
|
||||||
|
if (lore.cassettes.length) toast(lore.cassettes[index % lore.cassettes.length], 7600);
|
||||||
|
playTinySong();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (path.includes("/play/patience-game")) {
|
||||||
|
const target = document.querySelector("[data-patience-target]");
|
||||||
|
const count = document.querySelector("[data-patience-count]");
|
||||||
|
if (target && count) {
|
||||||
|
let seconds = 0;
|
||||||
|
setInterval(() => {
|
||||||
|
seconds += 1;
|
||||||
|
count.textContent = String(seconds);
|
||||||
|
if ([30, 90, 180].includes(seconds)) {
|
||||||
|
setHiddenMessage(target, "Patience milestone recorded.");
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (path.includes("/play/terminal-cupboard")) {
|
||||||
|
const input = document.querySelector("[data-cupboard-input]");
|
||||||
|
const log = document.querySelector("[data-cupboard-log]");
|
||||||
|
const commands = {
|
||||||
|
help: "commands: layer, exit",
|
||||||
|
layer: familyLayers.join("\n"),
|
||||||
|
exit: "Closed."
|
||||||
|
};
|
||||||
|
input?.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key !== "Enter") return;
|
||||||
|
const value = input.value.trim().toLowerCase();
|
||||||
|
const line = document.createElement("p");
|
||||||
|
setHiddenMessage(line, `> ${value}\n${commands[value] || "Unknown command."}`);
|
||||||
|
log.appendChild(line);
|
||||||
|
input.value = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addInvisibleHoverSecrets() {
|
||||||
|
document.querySelectorAll("h1, h2").forEach((heading, index) => {
|
||||||
|
if (index > 5) return;
|
||||||
|
heading.classList.add("hidden-hover-memory");
|
||||||
|
const source = [...lore.quotes, ...lore.journals, ...poems];
|
||||||
|
if (!source.length) return;
|
||||||
|
heading.dataset.hiddenMemory = pick(source, `heading-${index}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function playTinySong() {
|
||||||
|
try {
|
||||||
|
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||||
|
if (!AudioContext) return;
|
||||||
|
const ctx = new AudioContext();
|
||||||
|
const notes = [392, 494, 440, 330, 392];
|
||||||
|
notes.forEach((freq, index) => {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
osc.frequency.value = freq;
|
||||||
|
osc.type = "sine";
|
||||||
|
gain.gain.setValueAtTime(0.0001, ctx.currentTime + index * 0.18);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.05, ctx.currentTime + index * 0.18 + 0.03);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + index * 0.18 + 0.16);
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
osc.start(ctx.currentTime + index * 0.18);
|
||||||
|
osc.stop(ctx.currentTime + index * 0.18 + 0.18);
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value).replace(/[&<>"']/g, (char) => ({
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'"
|
||||||
|
}[char]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Features run in this order on every page after the DOM is ready.
|
||||||
|
// Each function receives the current `memory` object.
|
||||||
|
const FEATURES = [
|
||||||
|
addFooterNote,
|
||||||
|
addHomepageGreeting,
|
||||||
|
addWhispers,
|
||||||
|
addCornerObject,
|
||||||
|
addLogoSearchAndKeyboardSecrets,
|
||||||
|
addRareEvents,
|
||||||
|
addSecretLinks,
|
||||||
|
enhanceErrors,
|
||||||
|
addContinuity,
|
||||||
|
addSeasonAndDates,
|
||||||
|
addObjectConstellation,
|
||||||
|
addWeatherWindow,
|
||||||
|
addOldWebLayer,
|
||||||
|
addSourceRelics,
|
||||||
|
addLongKeyboardSecrets,
|
||||||
|
addPatienceRewards,
|
||||||
|
addRareSecondLayer,
|
||||||
|
addFamilyLayerIndex,
|
||||||
|
addPageSpecificSecrets,
|
||||||
|
addInvisibleHoverSecrets
|
||||||
|
];
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const memory = rememberVisit();
|
||||||
|
FEATURES.forEach((addFeature) => addFeature(memory));
|
||||||
|
});
|
||||||
|
}());
|
||||||
@@ -1,317 +1,7 @@
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
(function () {
|
||||||
if (typeof window.BiggerPicture !== 'function') {
|
"use strict";
|
||||||
console.error('[gallery-init] BiggerPicture not found. Check script path.');
|
var script = document.createElement("script");
|
||||||
return;
|
script.src = "/assets/scripts/ui/gallery-init.js";
|
||||||
}
|
script.defer = true;
|
||||||
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'm4v', 'ogv'];
|
document.head.appendChild(script);
|
||||||
|
}());
|
||||||
const isVideo = (href) => {
|
|
||||||
if (!href) return false;
|
|
||||||
href = href.toLowerCase();
|
|
||||||
return VIDEO_EXTENSIONS.some(ext => href.endsWith('.' + ext));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 1b) Convert MP4 links into inline <video> players
|
|
||||||
// Convert video links into inline <video> players BUT keep the <a>
|
|
||||||
const videoLinks = document.querySelectorAll('a[href]');
|
|
||||||
videoLinks.forEach(a => {
|
|
||||||
const href = a.getAttribute("href");
|
|
||||||
if (!isVideo(href)) return;
|
|
||||||
|
|
||||||
const video = document.createElement("video");
|
|
||||||
video.controls = true;
|
|
||||||
video.preload = "metadata";
|
|
||||||
video.style.maxWidth = "100%";
|
|
||||||
video.style.borderRadius = "6px";
|
|
||||||
|
|
||||||
const source = document.createElement("source");
|
|
||||||
source.src = href;
|
|
||||||
source.type = "video/" + href.split('.').pop(); // guesses correct mime
|
|
||||||
video.appendChild(source);
|
|
||||||
|
|
||||||
// Replace the link with the inline video
|
|
||||||
a.parentNode.replaceChild(video, a);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 1) Wrap Org-exported images so they’re clickable
|
|
||||||
const imgs = document.querySelectorAll('.figure img, img.org-svg');
|
|
||||||
imgs.forEach((img) => {
|
|
||||||
if (img.closest('a')) return; // already wrapped
|
|
||||||
const a = document.createElement('a');
|
|
||||||
const href = img.currentSrc || img.src;
|
|
||||||
a.href = href;
|
|
||||||
a.dataset.img = href; // lets BP pre-size/raster slides
|
|
||||||
a.dataset.alt = img.alt || '';
|
|
||||||
const setDims = () => {
|
|
||||||
a.dataset.width = img.naturalWidth || img.width || 1920;
|
|
||||||
a.dataset.height = img.naturalHeight || img.height || 1080;
|
|
||||||
};
|
|
||||||
if (img.complete) setDims(); else img.addEventListener('load', setDims);
|
|
||||||
img.style.cursor = 'zoom-in';
|
|
||||||
img.parentElement.insertBefore(a, img);
|
|
||||||
a.appendChild(img);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2) One global BP instance
|
|
||||||
const bp = BiggerPicture({ target: document.body });
|
|
||||||
|
|
||||||
// SVG pan/zoom handle
|
|
||||||
let activePanZoom = null;
|
|
||||||
const destroyPanZoom = () => { try { activePanZoom?.destroy(); } catch(_){} activePanZoom = null; };
|
|
||||||
|
|
||||||
// Simple rotate state (for non-SVG images)
|
|
||||||
let activeContainer = null;
|
|
||||||
let currentRotation = 0;
|
|
||||||
let rotateControls = null;
|
|
||||||
|
|
||||||
|
|
||||||
// 3) Build galleries per content container
|
|
||||||
const containers = document.querySelectorAll('main, article, .content, body');
|
|
||||||
containers.forEach((container) => {
|
|
||||||
//const links = Array.from(container.querySelectorAll('.figure a, a:has(img.org-svg)'));
|
|
||||||
const links = Array.from(container.querySelectorAll(
|
|
||||||
'.figure a, a:has(img.org-svg), a[href$=".mp4"], a[href$=".webm"], a[href$=".mov"], a[href$=".m4v"], a[href$=".ogv"]'
|
|
||||||
));
|
|
||||||
if (!links.length) return;
|
|
||||||
|
|
||||||
// Start the lightbox on click
|
|
||||||
links.forEach((link, index) => {
|
|
||||||
link.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
document.querySelectorAll(".theme-toggle").forEach(el => {
|
|
||||||
el.classList.add("hidden");
|
|
||||||
});
|
|
||||||
|
|
||||||
bp.open({
|
|
||||||
// IMPORTANT: pass the anchor ELEMENTS, not custom objects
|
|
||||||
items: links,
|
|
||||||
el: link,
|
|
||||||
caption: (el) => el.querySelector('img')?.alt || el.title || '',
|
|
||||||
maxZoom: 40, // for raster images (PNG/JPG); SVG handled separately
|
|
||||||
|
|
||||||
// Fade-out polish + cleanup
|
|
||||||
onClose(containerEl) {
|
|
||||||
destroyPanZoom();
|
|
||||||
teardownRotation();
|
|
||||||
if (containerEl) containerEl.classList.add('bp-fadeout');
|
|
||||||
const themeToggle = document.querySelector(".theme-toggle");
|
|
||||||
if (themeToggle) {
|
|
||||||
themeToggle.classList.remove("hidden");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Called once after open and on every slide change
|
|
||||||
//onOpen(containerEl) { setupRotation(containerEl); enhanceSVG(containerEl); }
|
|
||||||
onOpen(containerEl) {
|
|
||||||
const linkEl = bp.currItem;
|
|
||||||
const href = linkEl?.href || "";
|
|
||||||
|
|
||||||
if (href.endsWith(".mp4")) {
|
|
||||||
// Turn off image rotation (not applicable for video)
|
|
||||||
teardownRotation();
|
|
||||||
|
|
||||||
const htmlLayer = containerEl.querySelector(".bp-html");
|
|
||||||
const imgLayer = containerEl.querySelector(".bp-img");
|
|
||||||
|
|
||||||
if (!htmlLayer) return;
|
|
||||||
|
|
||||||
// Hide the default image layer
|
|
||||||
if (imgLayer) imgLayer.style.display = "none";
|
|
||||||
|
|
||||||
// Insert video player
|
|
||||||
htmlLayer.innerHTML = `
|
|
||||||
<video controls autoplay style="max-width:95vw; max-height:95vh">
|
|
||||||
<source src="${href}" type="video/mp4">
|
|
||||||
</video>
|
|
||||||
`;
|
|
||||||
|
|
||||||
return; // Do not run image/SVG enhancements
|
|
||||||
}
|
|
||||||
|
|
||||||
// Image behaviour
|
|
||||||
setupRotation(containerEl);
|
|
||||||
enhanceSVG(containerEl);
|
|
||||||
} ,
|
|
||||||
onUpdate(containerEl){ setupRotation(containerEl); enhanceSVG(containerEl); }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4) Simple rotate buttons for raster images
|
|
||||||
function ensureRotateControls() {
|
|
||||||
if (rotateControls) return rotateControls;
|
|
||||||
|
|
||||||
const wrapper = document.createElement('div');
|
|
||||||
wrapper.className = 'bp-rotate-controls';
|
|
||||||
Object.assign(wrapper.style, {
|
|
||||||
position: 'fixed',
|
|
||||||
bottom: '1.5rem',
|
|
||||||
right: '1.5rem',
|
|
||||||
display: 'flex',
|
|
||||||
gap: '0.5rem',
|
|
||||||
zIndex: '9999',
|
|
||||||
pointerEvents: 'auto'
|
|
||||||
});
|
|
||||||
|
|
||||||
const mkBtn = (label, title) => {
|
|
||||||
const btn = document.createElement('button');
|
|
||||||
btn.type = 'button';
|
|
||||||
btn.textContent = label;
|
|
||||||
btn.title = title;
|
|
||||||
btn.setAttribute('aria-label', title);
|
|
||||||
Object.assign(btn.style, {
|
|
||||||
padding: '0.4rem 0.6rem',
|
|
||||||
borderRadius: '999px',
|
|
||||||
border: 'none',
|
|
||||||
fontSize: '1.2rem',
|
|
||||||
cursor: 'pointer',
|
|
||||||
background: 'rgba(30,30,30,0.8)',
|
|
||||||
color: '#fff'
|
|
||||||
});
|
|
||||||
return btn;
|
|
||||||
};
|
|
||||||
|
|
||||||
const left = mkBtn('⟲', 'Rotate image 90° left');
|
|
||||||
const right = mkBtn('⟳', 'Rotate image 90° right');
|
|
||||||
|
|
||||||
left.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation(); // don’t close the lightbox
|
|
||||||
if (!activeContainer) return;
|
|
||||||
currentRotation = (currentRotation - 90 + 360) % 360;
|
|
||||||
applyRotation();
|
|
||||||
});
|
|
||||||
|
|
||||||
right.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (!activeContainer) return;
|
|
||||||
currentRotation = (currentRotation + 90) % 360;
|
|
||||||
applyRotation();
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.append(left, right);
|
|
||||||
document.body.appendChild(wrapper);
|
|
||||||
rotateControls = wrapper;
|
|
||||||
return rotateControls;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureRotateWrapper() {
|
|
||||||
if (!activeContainer) return null;
|
|
||||||
|
|
||||||
const imgRoot = activeContainer.querySelector('.bp-img');
|
|
||||||
if (!imgRoot) return null;
|
|
||||||
|
|
||||||
let imgEl = imgRoot.querySelector('img');
|
|
||||||
if (!imgEl) return null;
|
|
||||||
|
|
||||||
const src = (imgEl.currentSrc || imgEl.src || '').toLowerCase();
|
|
||||||
// We only rotate raster images; SVGs are handled via svg-pan-zoom
|
|
||||||
if (src.endsWith('.svg')) return null;
|
|
||||||
|
|
||||||
let wrapper = imgRoot.querySelector('.bp-rotate-wrapper');
|
|
||||||
if (!wrapper) {
|
|
||||||
wrapper = document.createElement('div');
|
|
||||||
wrapper.className = 'bp-rotate-wrapper';
|
|
||||||
wrapper.style.display = 'inline-block';
|
|
||||||
wrapper.style.transformOrigin = 'center center';
|
|
||||||
|
|
||||||
imgRoot.appendChild(wrapper);
|
|
||||||
wrapper.appendChild(imgEl);
|
|
||||||
} else if (!wrapper.contains(imgEl)) {
|
|
||||||
// Slide changed and BiggerPicture replaced the <img>
|
|
||||||
wrapper.innerHTML = '';
|
|
||||||
wrapper.appendChild(imgEl);
|
|
||||||
}
|
|
||||||
|
|
||||||
return wrapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyRotation() {
|
|
||||||
const wrapper = ensureRotateWrapper();
|
|
||||||
if (!wrapper) return;
|
|
||||||
wrapper.style.transform = `rotate(${currentRotation}deg)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupRotation(containerEl) {
|
|
||||||
activeContainer = containerEl;
|
|
||||||
currentRotation = 0;
|
|
||||||
const controls = ensureRotateControls();
|
|
||||||
controls.style.display = 'flex';
|
|
||||||
applyRotation();
|
|
||||||
}
|
|
||||||
|
|
||||||
function teardownRotation() {
|
|
||||||
activeContainer = null;
|
|
||||||
currentRotation = 0;
|
|
||||||
if (rotateControls) {
|
|
||||||
rotateControls.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// 4) If current slide is an SVG, swap to inline + enable svg-pan-zoom
|
|
||||||
async function enhanceSVG(containerEl) {
|
|
||||||
try {
|
|
||||||
destroyPanZoom();
|
|
||||||
|
|
||||||
const imgEl = containerEl.querySelector('.bp-img img');
|
|
||||||
if (!imgEl) return;
|
|
||||||
|
|
||||||
const src = imgEl.currentSrc || imgEl.src || '';
|
|
||||||
const isSVG = src.toLowerCase().endsWith('.svg');
|
|
||||||
const htmlLayer = containerEl.querySelector('.bp-html');
|
|
||||||
if (!isSVG || !htmlLayer) {
|
|
||||||
// ensure any previous holder is removed and bitmap is visible
|
|
||||||
const old = htmlLayer?.querySelector('.bp-svg-holder');
|
|
||||||
if (old) old.remove();
|
|
||||||
imgEl.style.visibility = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create/clear holder
|
|
||||||
let holder = htmlLayer.querySelector('.bp-svg-holder');
|
|
||||||
if (!holder) {
|
|
||||||
holder = document.createElement('div');
|
|
||||||
holder.className = 'bp-svg-holder';
|
|
||||||
holder.style.maxWidth = '95vw';
|
|
||||||
holder.style.maxHeight = '95vh';
|
|
||||||
htmlLayer.appendChild(holder);
|
|
||||||
}
|
|
||||||
holder.innerHTML = '';
|
|
||||||
|
|
||||||
// Hide the bitmap so only the inline SVG shows
|
|
||||||
imgEl.style.visibility = 'hidden';
|
|
||||||
|
|
||||||
// Inline the SVG
|
|
||||||
const res = await fetch(src, { cache: 'force-cache' });
|
|
||||||
const text = await res.text();
|
|
||||||
holder.innerHTML = text;
|
|
||||||
|
|
||||||
const svg = holder.querySelector('svg');
|
|
||||||
if (!svg) { imgEl.style.visibility = ''; return; }
|
|
||||||
|
|
||||||
svg.style.maxWidth = '95vw';
|
|
||||||
svg.style.maxHeight = '95vh';
|
|
||||||
svg.style.display = 'block';
|
|
||||||
|
|
||||||
if (typeof window.svgPanZoom === 'function') {
|
|
||||||
activePanZoom = svgPanZoom(svg, {
|
|
||||||
zoomEnabled: true,
|
|
||||||
controlIconsEnabled: true,
|
|
||||||
fit: true,
|
|
||||||
center: true,
|
|
||||||
minZoom: 0.05,
|
|
||||||
maxZoom: 400, // effectively "unlimited"
|
|
||||||
zoomScaleSensitivity: 0.25,
|
|
||||||
dblClickZoomEnabled: true
|
|
||||||
});
|
|
||||||
// Keep wheel inside lightbox
|
|
||||||
holder.addEventListener('wheel', (e) => e.stopPropagation(), { passive: true });
|
|
||||||
} else {
|
|
||||||
console.warn('[gallery-init] svg-pan-zoom not loaded');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[gallery-init] SVG enhance failed:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||