Compare commits
193 Commits
8441402f49
...
zq/persona
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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 ./gitea-build-monitor.ps1
|
||||
7
.gitignore
vendored
@@ -1,4 +1,7 @@
|
||||
.venv/
|
||||
output/
|
||||
backup/
|
||||
*~
|
||||
.org-timestamps/
|
||||
backups/
|
||||
__pycache__/
|
||||
*~
|
||||
.syncthing*.tmp
|
||||
|
||||
5
.projectile
Executable file
@@ -0,0 +1,5 @@
|
||||
-.venv/
|
||||
-output/
|
||||
-backup/
|
||||
-*~
|
||||
-.packages/
|
||||
73
Makefile
@@ -1,19 +1,33 @@
|
||||
.PHONY: all clean norm help
|
||||
.PHONY: all clean norm test test-elisp author author-install author-start author-stop author-stop-legacy author-restart author-status author-logs author-health author-diagnostics help
|
||||
|
||||
VENV := .venv
|
||||
PY := $(VENV)/bin/python
|
||||
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:
|
||||
@echo "Building project..."
|
||||
emacs -Q --script build-site.el
|
||||
|
||||
set-perms:
|
||||
@echo "Setting permissions for output directory..."
|
||||
cd $(PERM_DIR) && sudo ./master-perms.sh
|
||||
|
||||
search: $(VENV)
|
||||
@echo "Generating search index"
|
||||
$(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
|
||||
|
||||
$(VENV):
|
||||
@echo "Generating virtual environment"
|
||||
python3 -m venv $(VENV)
|
||||
@@ -21,7 +35,7 @@ $(VENV):
|
||||
|
||||
clean-output:
|
||||
@echo "Cleaning output directory..."
|
||||
rm -rf output/
|
||||
rm -rf "$(OUTPUT_DIR)"
|
||||
|
||||
clean-venv:
|
||||
@echo "Cleaning virtual environment..."
|
||||
@@ -31,18 +45,51 @@ norm:
|
||||
@echo "sorting out the backups..."
|
||||
find . -path ./backups -prune -o -type f -name '*~' -exec mv {} backups/ \;
|
||||
|
||||
fix-permissions:
|
||||
echo "shakkal123" | sudo -S chown -R zaine:zaine .
|
||||
author: author-start
|
||||
|
||||
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:
|
||||
@echo "Available targets:"
|
||||
@echo " make - Full rebuild"
|
||||
@echo " make build - Rebuild the output directory"
|
||||
@echo " make clean-output - Remove output directory"
|
||||
@echo " make clean-venv - Remove venv directory"
|
||||
@echo " make norm - Move all files that end with ~ to the backup folder"
|
||||
@echo " make search - creates the search index"
|
||||
@echo " make help - Show this help message"
|
||||
@echo " make - Full rebuild"
|
||||
@echo " make build - Rebuild the output directory"
|
||||
@echo " make set-perms - Set permissions for output directory"
|
||||
@echo " make clean-output - Remove output directory"
|
||||
@echo " make clean-venv - Remove venv directory"
|
||||
@echo " make norm - Move backup files"
|
||||
@echo " make test - Run authoring server tests"
|
||||
@echo " make test-elisp - Run Emacs Lisp build 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"
|
||||
|
||||
26
README.md
@@ -3,3 +3,29 @@
|
||||
This repository contains code for the website https://zainezq.com, which serves as a central familial hub for me.
|
||||
|
||||
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.
|
||||
|
||||
Asset structure and frontend boundaries are documented in `docs/assets-architecture.md`.
|
||||
|
||||
## Local authoring UI
|
||||
|
||||
The authoring UI now lives in `/home/zaine/master-folder/org-platform/authoring-service`.
|
||||
|
||||
Run `make author` from either repository to install and start the user systemd service, then open `http://127.0.0.1:8765` to create or edit blog and post Org files from a browser. When deployed behind the reverse proxy, use `https://author.zainezq.com`.
|
||||
|
||||
Useful service commands:
|
||||
|
||||
- `make author-restart` restarts the service and waits for the page API to respond
|
||||
- `make author-stop` stops the service
|
||||
- `make author-status` shows systemd status and the editable page count
|
||||
- `make author-logs` shows recent journal logs
|
||||
|
||||
The editor writes normal `.org` pages with the metadata used by the publishing pipeline:
|
||||
|
||||
- blogs default to `blogs/YYYY/MM-month/slug.org`
|
||||
- posts default to `posts/slug.org`, or `posts/section/slug.org` when a section is provided
|
||||
- new pages can use an explicit path under `blogs/` or `posts/`
|
||||
- saving a page starts `emacs -Q --script build-site.el` and then regenerates the search index
|
||||
|
||||
Lima pages are Markdown files under `lima/`. Selecting the Lima page type shows a Markdown toolbar for common formatting and an attachment button. Uploaded images and videos are stored under `assets/images/hzone/YYYY/MM/` and inserted into the Markdown page.
|
||||
|
||||
Saves enqueue builds. The editor remains usable while publishing runs, and the queue section shows the current build, pending builds, and recent build results.
|
||||
|
||||
517
assets/content/comment-pages.json
Normal file
@@ -0,0 +1,517 @@
|
||||
[
|
||||
{
|
||||
"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": "cleaning-up-the-repos",
|
||||
"title": "Cleaning up the repos",
|
||||
"url": "/blogs/2026/06-june/cleaning-up-the-repos.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"
|
||||
}
|
||||
]
|
||||
6
assets/content/hidden-details.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 3,
|
||||
"generatedAt": "2026-06-17T20:42:16",
|
||||
"entries": [],
|
||||
"stories": []
|
||||
}
|
||||
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
Normal 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
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
assets/images/blogs/2026-05-12-screenshot-2026-05-12-163301.png
Normal file
|
After Width: | Height: | Size: 860 KiB |
BIN
assets/images/blogs/2026-05-13-200.gif
Normal file
|
After Width: | Height: | Size: 343 KiB |
BIN
assets/images/blogs/2026-05-13-image.jpg
Normal file
|
After Width: | Height: | Size: 307 KiB |
BIN
assets/images/blogs/2026-05-13-poppadoms.jpg
Normal file
|
After Width: | Height: | Size: 217 KiB |
BIN
assets/images/blogs/2026-05-13-screenshot-2026-05-13-131807.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
assets/images/blogs/2026-05-13-spinning-spin.webp
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
assets/images/blogs/2026-05-27-screenshot-2026-05-27-160508.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
assets/images/blogs/2026-06-04-screenshot-2026-06-04-143726.png
Normal 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
Normal 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 |
0
assets/images/reviews/timesheets/timesheet-01-02.png → assets/images/reviews/timesheets/2026-02-01-timesheet.png
Executable file → Normal file
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
0
assets/images/reviews/timesheets/timesheet-08-02.png → assets/images/reviews/timesheets/2026-02-08-timesheet.png
Executable file → Normal file
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
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
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
assets/images/reviews/timesheets/2026-05-17-timesheet.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
assets/images/reviews/timesheets/2026-05-24-timesheet.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
assets/images/reviews/timesheets/2026-05-31-timesheet.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
32
assets/scripts/application/theme-switcher.js
Normal 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();
|
||||
});
|
||||
})();
|
||||
7
assets/scripts/ash-below-lake.js
Normal file
@@ -0,0 +1,7 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
var script = document.createElement("script");
|
||||
script.src = "/assets/scripts/pages/ash-below-lake.js";
|
||||
script.defer = true;
|
||||
document.head.appendChild(script);
|
||||
}());
|
||||
8
assets/scripts/bigger-picture.min.js
vendored
@@ -1,197 +1,7 @@
|
||||
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;
|
||||
|
||||
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();
|
||||
(function () {
|
||||
"use strict";
|
||||
var script = document.createElement("script");
|
||||
script.src = "/assets/scripts/features/comments.js";
|
||||
script.defer = true;
|
||||
document.head.appendChild(script);
|
||||
}());
|
||||
|
||||
@@ -1,240 +1,7 @@
|
||||
const STATES = [
|
||||
{ key: "completed", label: "Completed" },
|
||||
{ key: "manager_review", label: "Manager Review" },
|
||||
{ key: "in_progress", label: "In Progress" },
|
||||
{ key: "not_started", label: "Not Started" },
|
||||
{ key: "comments", label: "Comments" },
|
||||
];
|
||||
|
||||
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();
|
||||
(function () {
|
||||
"use strict";
|
||||
var script = document.createElement("script");
|
||||
script.src = "/assets/scripts/pages/competency-status-board.js";
|
||||
script.defer = true;
|
||||
document.head.appendChild(script);
|
||||
}());
|
||||
|
||||
196
assets/scripts/features/comments.js
Normal 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();
|
||||
624
assets/scripts/features/hidden-details.js
Normal file
@@ -0,0 +1,624 @@
|
||||
(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 = [];
|
||||
|
||||
const poems = [];
|
||||
|
||||
const greetings = [];
|
||||
|
||||
const nightMessages = [];
|
||||
|
||||
const lore = {
|
||||
"quotes": [],
|
||||
"conversations": [],
|
||||
"journals": [],
|
||||
"warnings": [],
|
||||
"dreams": [],
|
||||
"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', () => {
|
||||
if (typeof window.BiggerPicture !== 'function') {
|
||||
console.error('[gallery-init] BiggerPicture not found. Check script path.');
|
||||
return;
|
||||
}
|
||||
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'm4v', 'ogv'];
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
(function () {
|
||||
"use strict";
|
||||
var script = document.createElement("script");
|
||||
script.src = "/assets/scripts/ui/gallery-init.js";
|
||||
script.defer = true;
|
||||
document.head.appendChild(script);
|
||||
}());
|
||||
|
||||