Compare commits
42 Commits
zq/persona
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74924ea06f | ||
|
|
4653a2caae | ||
|
|
7a60d33610 | ||
|
|
0b4a68b81b | ||
|
|
65885dbb3a | ||
|
|
8dbeca7570 | ||
|
|
b8bb1944dd | ||
|
|
75af3e1f0e | ||
|
|
d12edea8de | ||
|
|
2ee6575539 | ||
|
|
08516fda14 | ||
|
|
262d943443 | ||
|
|
4659edc658 | ||
|
|
563d4b63cc | ||
|
|
6066f486de | ||
|
|
a6502b168d | ||
|
|
15b626ee0d | ||
|
|
a0c78929b3 | ||
|
|
fe8acac707 | ||
|
|
dc1ca5c00e | ||
|
|
2f125f32ae | ||
|
|
c06af38d80 | ||
|
|
5b59f5745c | ||
|
|
7afc554f5d | ||
|
|
db90e9f452 | ||
|
|
8dd05d4d03 | ||
|
|
67ab0d2ddb | ||
|
|
9d758f5254 | ||
|
|
4df4cd1055 | ||
|
|
2fb88916d5 | ||
|
|
e82fdf1230 | ||
|
|
f4b388d70b | ||
|
|
90c8321ba3 | ||
|
|
4c9294490e | ||
|
|
0d56f36a26 | ||
|
|
275a955a5f | ||
|
|
af79b439ce | ||
|
|
17e02977d6 | ||
|
|
9b259ac173 | ||
|
|
2bb816b2ae | ||
|
|
76a708e8fc | ||
| 1883097397 |
@@ -57,4 +57,4 @@ jobs:
|
|||||||
API_KEY: ${{ secrets.API_KEY }}
|
API_KEY: ${{ secrets.API_KEY }}
|
||||||
run: |
|
run: |
|
||||||
cd /home/zaine/master-folder/org-platform/org_web
|
cd /home/zaine/master-folder/org-platform/org_web
|
||||||
/snap/bin/pwsh -NoProfile -File ./gitea-build-monitor.ps1
|
/snap/bin/pwsh -NoProfile -File ./build-logs/gitea-build-monitor.ps1
|
||||||
|
|||||||
2
.gitignore
vendored
@@ -1,4 +1,6 @@
|
|||||||
.venv/
|
.venv/
|
||||||
|
node_modules/
|
||||||
|
assets/games/two-rivers/images/characters/*-source.png
|
||||||
output/
|
output/
|
||||||
.org-timestamps/
|
.org-timestamps/
|
||||||
backups/
|
backups/
|
||||||
|
|||||||
6
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.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
|
.PHONY: all clean norm test test-elisp test-js author author-install author-start author-stop author-stop-legacy author-restart author-status author-logs author-health author-diagnostics help
|
||||||
|
|
||||||
VENV := .venv
|
VENV := .venv
|
||||||
PY := $(VENV)/bin/python
|
PY := $(VENV)/bin/python
|
||||||
@@ -28,6 +28,9 @@ test:
|
|||||||
test-elisp:
|
test-elisp:
|
||||||
emacs -Q --batch -l lisp/tests/build-site-tests.el -f ert-run-tests-batch-and-exit
|
emacs -Q --batch -l lisp/tests/build-site-tests.el -f ert-run-tests-batch-and-exit
|
||||||
|
|
||||||
|
test-js:
|
||||||
|
node --test tests/*.test.js
|
||||||
|
|
||||||
$(VENV):
|
$(VENV):
|
||||||
@echo "Generating virtual environment"
|
@echo "Generating virtual environment"
|
||||||
python3 -m venv $(VENV)
|
python3 -m venv $(VENV)
|
||||||
@@ -84,6 +87,7 @@ help:
|
|||||||
@echo " make norm - Move backup files"
|
@echo " make norm - Move backup files"
|
||||||
@echo " make test - Run authoring server tests"
|
@echo " make test - Run authoring server tests"
|
||||||
@echo " make test-elisp - Run Emacs Lisp build tests"
|
@echo " make test-elisp - Run Emacs Lisp build tests"
|
||||||
|
@echo " make test-js - Run browser-model JavaScript tests"
|
||||||
@echo " make author - Install and start the authoring UI service"
|
@echo " make author - Install and start the authoring UI service"
|
||||||
@echo " make author-install - Install and enable 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-stop - Stop the authoring UI service"
|
||||||
|
|||||||
412
README.md
@@ -1,31 +1,405 @@
|
|||||||
# Introduction
|
# zxh / org-web
|
||||||
|
|
||||||
This repository contains code for the website https://zainezq.com, which serves as a central familial hub for me.
|
This repository is the source and publishing pipeline for [zainezq.com](https://zainezq.com), a personal and family website built primarily from Emacs Org-mode files. It contains the written content, the Emacs Lisp build system, browser assets, generated indexes, playful interactive pages, and the Gitea Actions workflow that publishes the site.
|
||||||
|
|
||||||
The site is written using Emacs and Org-mode, and exported using the `org-publish` functionality. See the `build-site.el` script for details on how the site is built and published.
|
This README is the central technical guide for the repository: start here for architecture, authoring, builds, frontend work, testing, and deployment.
|
||||||
|
|
||||||
Asset structure and frontend boundaries are documented in `docs/assets-architecture.md`.
|
## System overview
|
||||||
|
|
||||||
## Local authoring UI
|
The application has three boundaries:
|
||||||
|
|
||||||
The authoring UI now lives in `/home/zaine/master-folder/org-platform/authoring-service`.
|
1. **Site source (this repository):** Org/Markdown content, Emacs Lisp publishing code, CSS, JavaScript, images, and build tooling.
|
||||||
|
2. **Static output (**`output/`**):** generated HTML, copied assets, and `search-index.json`. This is the deployable website, not the source of truth.
|
||||||
|
3. **Runtime services (outside this repository):** the authoring service and the same-origin APIs used by interactive pages. The static site remains readable without them, but the relevant dynamic features will not work.
|
||||||
|
|
||||||
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`.
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Author["Author or authoring service"] --> Source["Org and Markdown source"]
|
||||||
|
Source --> Emacs["Emacs org-publish pipeline"]
|
||||||
|
Assets["CSS, JavaScript, images, fonts"] --> Emacs
|
||||||
|
Emacs --> HTML["output/: HTML and assets"]
|
||||||
|
HTML --> Search["Python search indexer"]
|
||||||
|
Search --> Deploy["Static site at zainezq.com"]
|
||||||
|
Deploy <-->|"same-origin /api requests"| APIs["Runtime APIs outside this repo"]
|
||||||
|
```
|
||||||
|
|
||||||
Useful service commands:
|
## Repository map
|
||||||
|
|
||||||
- `make author-restart` restarts the service and waits for the page API to respond
|
| Path | Purpose | Source or generated? |
|
||||||
- `make author-stop` stops the service
|
|----------------------------|------------------------------------------------------------|----------------------------------------------------------------|
|
||||||
- `make author-status` shows systemd status and the editable page count
|
| `index.org` | Home page source | Source |
|
||||||
- `make author-logs` shows recent journal logs
|
| `blogs/` | Dated personal writing, grouped by year/month | Source, except generated list pages |
|
||||||
|
| `posts/` | Longer-lived posts; `posts/career/` has its own index | Source, except generated list pages |
|
||||||
|
| `home/` | Utility and dashboard-style pages | Source, except `categories.org` |
|
||||||
|
| `play/` | Interactive and experimental pages | Source |
|
||||||
|
| `rl/` | Server-synced Resource Loader SPA | Source |
|
||||||
|
| `lima/` | Org or Markdown pages with special attachment handling | Source, except `lima-list.org` |
|
||||||
|
| `assets/` | Browser code, styles, fonts, images, and content manifests | Mostly source; some manifests/scripts are generated externally |
|
||||||
|
| `lisp/` | Modular Emacs Lisp publishing system | Source |
|
||||||
|
| `lisp/tests/` | ERT tests for the publishing logic | Source |
|
||||||
|
| `tags/` | Per-tag Org pages created during a build | Generated |
|
||||||
|
| `recently-updated.org` | Recent-content list created during a build | Generated |
|
||||||
|
| `sitemap.org` and `*-list.org` | Sitemap/list sources created by `org-publish` | Generated |
|
||||||
|
| `output/` | Complete publish output | Generated |
|
||||||
|
| `.org-timestamps/` | Incremental publish cache | Generated |
|
||||||
|
| `.packages/` | Repository-local Emacs package directory | Generated/cache |
|
||||||
|
| `build-logs/` | Gitea build monitoring script, cache, and logs | Mixed; script is source, logs/cache are runtime data |
|
||||||
|
| `docs/` | Historical/design notes for individual features | Supplemental; this README is the central engineering guide |
|
||||||
|
|
||||||
The editor writes normal `.org` pages with the metadata used by the publishing pipeline:
|
## Build architecture
|
||||||
|
|
||||||
- blogs default to `blogs/YYYY/MM-month/slug.org`
|
`build-site.el` is the entry point. It establishes absolute source/output roots, bootstraps `htmlize`, configures Org, loads the modules in `lisp/`, defines the publishing projects, and runs the build.
|
||||||
- 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.
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Entry["build-site.el"] --> Core["build-core.el: packages, Org, logging"]
|
||||||
|
Core --> Modules["Load build modules"]
|
||||||
|
Modules --> Projects["build-projects.el: org-publish projects"]
|
||||||
|
Projects --> Prepare["Validate output and timestamp cache"]
|
||||||
|
Prepare --> Inputs["Generate tag pages and comment manifest"]
|
||||||
|
Inputs --> Publish["org-publish-all"]
|
||||||
|
Publish --> OrgHTML["Org/Markdown to HTML"]
|
||||||
|
Publish --> AssetCopy["Copy assets"]
|
||||||
|
OrgHTML --> Sync["Final asset sync"]
|
||||||
|
AssetCopy --> Sync
|
||||||
|
Sync --> Summary["Build summary"]
|
||||||
|
```
|
||||||
|
|
||||||
Saves enqueue builds. The editor remains usable while publishing runs, and the queue section shows the current build, pending builds, and recent build results.
|
### Build modules
|
||||||
|
|
||||||
|
| Module | Responsibility |
|
||||||
|
|---------------------|-------------------------------------------------------------------------------------------------------|
|
||||||
|
| `build-core.el` | Logging, timing, local package bootstrap, and base Org configuration |
|
||||||
|
| `build-assets.el` | Shared CSS/JS `<head>` manifest, cache-busted asset URLs, and asset synchronization |
|
||||||
|
| `build-html.el` | Custom HTML backend, page shell, Mermaid export, macros, body classes, tags, and comments |
|
||||||
|
| `build-projects.el` | `org-publish-project-alist` for the main site, posts, blogs, career, tags, Lima, categories, and assets |
|
||||||
|
| `build-runner.el` | Cache integrity, generated inputs, publishing, and build metrics |
|
||||||
|
| `build-lima.el` | Lima Org/Markdown conversion and neighboring attachment copying |
|
||||||
|
| `build-comments.el` | Comment opt-in detection, comment HTML injection, and comment-page manifest generation |
|
||||||
|
| `sitemaps.el` | Main Mermaid sitemap and specialized blog/post/career/category lists |
|
||||||
|
| `tags.el` | Tag discovery, generated tag pages, and tag markup in exported HTML |
|
||||||
|
| `recently-updated.el` | Recent page list generation using `#+DATE` with mtime fallback |
|
||||||
|
| `sidenotes.el` | Sidenote suppression rules for special page layouts |
|
||||||
|
|
||||||
|
The main `org-main` project recursively publishes every Org file, while narrower projects provide specialized sitemap behavior and options. That overlap is intentional: Org's timestamp cache prevents unnecessary repeated work in normal incremental builds.
|
||||||
|
|
||||||
|
### Generated inputs and outputs
|
||||||
|
|
||||||
|
Before publishing, the build writes:
|
||||||
|
|
||||||
|
- `tags/*.org` from `#+FILETAGS`/`#+TAGS` in posts and blogs.
|
||||||
|
- `assets/content/comment-pages.json` from pages with `#+COMMENTS: t`.
|
||||||
|
- `recently-updated.org` from the 26 most recent eligible Org pages.
|
||||||
|
- sitemap/list Org files through `org-publish`.
|
||||||
|
|
||||||
|
After publishing, `search-index-json.py` walks generated HTML, strips script/style markup with Beautiful Soup, and writes `output/search-index.json`. The browser builds a Lunr index from that JSON at page load.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Metadata["Org metadata"] --> Tags["tags/*.org"]
|
||||||
|
Metadata --> Comments["comment-pages.json"]
|
||||||
|
Metadata --> Recent["recently-updated.org"]
|
||||||
|
Tags --> Publish["HTML publish"]
|
||||||
|
Comments --> Publish
|
||||||
|
Recent --> Publish
|
||||||
|
Publish --> Indexer["search-index-json.py"]
|
||||||
|
Indexer --> SearchJSON["output/search-index.json"]
|
||||||
|
SearchJSON --> Lunr["Browser Lunr index"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
For a full local build, install:
|
||||||
|
|
||||||
|
- GNU Emacs with Org mode (Org is bundled with normal Emacs distributions).
|
||||||
|
- Python 3 with `venv` support.
|
||||||
|
- `pandoc`, required when publishing Markdown files under `lima/`.
|
||||||
|
- Network access on the first build if `htmlize` is not already cached in `.packages/`.
|
||||||
|
|
||||||
|
Python packages are listed in `requirements.txt` and installed into `.venv` by Make. The default `make` workflow also invokes a machine-specific permission script with `sudo`; use the direct build commands below on machines that do not have that script.
|
||||||
|
|
||||||
|
## Build and development commands
|
||||||
|
|
||||||
|
### Portable local build
|
||||||
|
|
||||||
|
```sh
|
||||||
|
emacs -Q --script build-site.el
|
||||||
|
python3 -m venv .venv
|
||||||
|
.venv/bin/pip install -r requirements.txt
|
||||||
|
.venv/bin/python search-index-json.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The generated site is in `output/`. Serve it over HTTP so fetch-based features work correctly, for example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m http.server 8000 --directory output
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `http://127.0.0.1:8000`. Dynamic `/api/...` features require the external runtime services or an equivalent local proxy.
|
||||||
|
|
||||||
|
### Make targets
|
||||||
|
|
||||||
|
| Command | Effect |
|
||||||
|
|-------------------|-----------------------------------------------------------------------------------------|
|
||||||
|
| `make build` | Run the Emacs publishing pipeline |
|
||||||
|
| `make search` | Create/update `output/search-index.json`, creating `.venv` when needed |
|
||||||
|
| `make` | Set machine-specific permissions, remove `output/`, build, index search, and remove `.venv` |
|
||||||
|
| `make clean-output` | Delete the configured output directory |
|
||||||
|
| `make test-elisp` | Run the Emacs Lisp ERT suite |
|
||||||
|
| `make test` | Run tests in the sibling authoring-service repository |
|
||||||
|
| `make norm` | Move Emacs backup files into `backups/` |
|
||||||
|
| `make help` | List supported targets |
|
||||||
|
|
||||||
|
### Build environment variables
|
||||||
|
|
||||||
|
| Variable | Meaning |
|
||||||
|
|-----------------------|--------------------------------------------------------------------------|
|
||||||
|
| `SITE_OUTPUT_DIR=/path` | Publish somewhere other than `output/`; the Python indexer respects it too |
|
||||||
|
| `SITE_FORCE=1` | Republish every file instead of using Org timestamps |
|
||||||
|
| `SITE_DRY=1` | Generate/prepare inputs but skip `org-publish-all` |
|
||||||
|
| `SITE_CLEAN=1` | Delete output and `.org-timestamps/`, then force a full publish |
|
||||||
|
|
||||||
|
The build checks that assets and an entry page exist in the output. If the output is missing or incomplete while timestamps remain, it clears the timestamp cache and forces a full publish to avoid a falsely successful incremental build.
|
||||||
|
|
||||||
|
## Authoring content
|
||||||
|
|
||||||
|
### Org pages
|
||||||
|
|
||||||
|
A typical page is:
|
||||||
|
|
||||||
|
```org
|
||||||
|
#+TITLE: Page title
|
||||||
|
#+DATE: <2026-07-12 Sun 12:00>
|
||||||
|
#+FILETAGS: :topic:notes:
|
||||||
|
#+OPTIONS: num:nil toc:t
|
||||||
|
#+COMMENTS: t
|
||||||
|
#+SLUG: stable-page-slug
|
||||||
|
|
||||||
|
* First heading
|
||||||
|
|
||||||
|
Content goes here.
|
||||||
|
```
|
||||||
|
|
||||||
|
The important metadata is:
|
||||||
|
|
||||||
|
- `#+TITLE`: displayed title and sitemap/search label.
|
||||||
|
- `#+DATE`: canonical publication time and ordering source. Recently updated falls back to filesystem modification time when absent or invalid.
|
||||||
|
- `#+FILETAGS`: colon-delimited tags used for category/tag pages and rendered under the page title.
|
||||||
|
- `#+OPTIONS`: normal Org export controls such as `toc:nil`, `num:nil`, or `title:nil`.
|
||||||
|
- `#+COMMENTS: t`: injects the comment UI and adds the page to the comment manifest.
|
||||||
|
- `#+SLUG`: stable comment API identifier; defaults to the filename when omitted.
|
||||||
|
- `#+NO_SIDENOTES`: any non-empty value adds the `no-sidenotes` body class. The `NO_SIDENOTES` or `KANBAN` file tag and a heading containing `KANBAN` do the same.
|
||||||
|
|
||||||
|
Use root-relative URLs (`/assets/...`) for shared site assets. Relative Org file links are rewritten to their corresponding HTML links by the exporter.
|
||||||
|
|
||||||
|
### Supported export helpers
|
||||||
|
|
||||||
|
Mermaid diagrams can be written directly in Org:
|
||||||
|
|
||||||
|
```org
|
||||||
|
#+begin_src mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[Write] --> B[Publish]
|
||||||
|
#+end_src
|
||||||
|
```
|
||||||
|
|
||||||
|
The custom HTML backend emits a Mermaid container, and the shared browser initializer renders it. The build also provides these Org macros:
|
||||||
|
|
||||||
|
- `{{{sidenote(id,text)}}}`
|
||||||
|
- `{{{epigraph(quote,attribution)}}}` and related epigraph variants
|
||||||
|
- `{{{kbd(text)}}}`
|
||||||
|
- `{{{margimg(url,alt,caption)}}}`
|
||||||
|
- `{{{countdown(datetime,label)}}}`
|
||||||
|
|
||||||
|
### Where new pages belong
|
||||||
|
|
||||||
|
- Blog entry: `blogs/YYYY/MM-month/slug.org`.
|
||||||
|
- General post: `posts/slug.org`.
|
||||||
|
- Career post: `posts/career/slug.org`.
|
||||||
|
- Utility/dashboard page: `home/slug.org`.
|
||||||
|
- Interactive experiment: `play/slug.org`, usually paired with `assets/scripts/pages/slug.js` and `assets/styles/pages/slug.css`.
|
||||||
|
- Lima page: `.org` or `.md` under `lima/`.
|
||||||
|
|
||||||
|
Do not manually maintain generated list, category, recent, tag, or output files; rebuild them from source.
|
||||||
|
|
||||||
|
### Lima Markdown publishing
|
||||||
|
|
||||||
|
Markdown under `lima/` is converted to temporary Org with Pandoc, given generated title/date/comment metadata, and then exported through the normal HTML backend. The first Markdown H1 becomes the page title. Sibling directories matching `.attachments.*` are copied beside the published page. Org files in `lima/` use the same attachment-copy behavior without Pandoc.
|
||||||
|
|
||||||
|
## Frontend architecture
|
||||||
|
|
||||||
|
All static browser assets live under `assets/` and are copied to `output/assets/`. `lisp/build-assets.el` is the authoritative list of globally loaded styles and scripts; it appends deterministic mtime/size query strings for cache busting.
|
||||||
|
|
||||||
|
### Styles
|
||||||
|
|
||||||
|
The modular CSS hierarchy is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
assets/styles/
|
||||||
|
├── themes/ design tokens and themes
|
||||||
|
├── base/ element defaults
|
||||||
|
├── layout/ site shell and layout
|
||||||
|
├── components/ reusable UI components
|
||||||
|
├── pages/ page-specific presentation
|
||||||
|
├── features/ optional feature presentation
|
||||||
|
├── utilities/ responsive/utility rules
|
||||||
|
└── vendor/ third-party CSS
|
||||||
|
```
|
||||||
|
|
||||||
|
Top-level CSS files are compatibility manifests that import the modular files. Prefer editing the modular source, while preserving the top-level entry points until old handwritten references have been removed.
|
||||||
|
|
||||||
|
### Scripts
|
||||||
|
|
||||||
|
The intended JavaScript boundaries are:
|
||||||
|
|
||||||
|
```text
|
||||||
|
assets/scripts/
|
||||||
|
├── application/ cross-page use-case orchestration
|
||||||
|
├── ui/ DOM presentation behavior
|
||||||
|
├── pages/ page-specific applications
|
||||||
|
├── features/ optional site-wide features
|
||||||
|
└── vendor/ vendored third-party libraries
|
||||||
|
```
|
||||||
|
|
||||||
|
The shared script list is loaded on every page, so each module must safely exit when its expected DOM is absent. Page modules should own page composition; reusable browser behavior belongs in `ui/` or `features/`. Top-level duplicate filenames are legacy loaders or compatibility copies—edit the modular file referenced by `build-assets.el` unless a page explicitly loads the legacy path.
|
||||||
|
|
||||||
|
Third-party browser dependencies are vendored: Lunr, Mermaid, BiggerPicture, and SVG Pan Zoom. Avoid editing minified vendor files directly.
|
||||||
|
|
||||||
|
## Dynamic features and service boundaries
|
||||||
|
|
||||||
|
The published HTML is static, but some browser modules rely on same-origin APIs:
|
||||||
|
|
||||||
|
| Feature | Browser module | Runtime endpoint |
|
||||||
|
|----------------------|----------------------------------|--------------------------------------------------|
|
||||||
|
| Comments and replies | `features/comments.js` | `GET /api/comments/:slug`, `POST /api/comments` |
|
||||||
|
| Notes board | `pages/notes.js` | `/api/notes` |
|
||||||
|
| Wird tracker | `pages/wird-tracker.js` | `/api/wird`, `/api/wird/motalah`, `/api/calibre/books` |
|
||||||
|
| Competency board | `pages/competency-status-board.js` | `/api/competencies/items...` |
|
||||||
|
| Resource Loader | `pages/resource-loader.js` | `/api/resource-loader`, `/api/resource-loader/thumbnails/:id` |
|
||||||
|
|
||||||
|
These APIs are not implemented here. Production routing must serve `output/` and proxy `/api/` to the appropriate backend. When changing an endpoint contract, coordinate the static client and its external service.
|
||||||
|
|
||||||
|
The hidden-details feature uses browser `localStorage`/`sessionStorage` for visit and discovery state and may call Open-Meteo after explicit geolocation interaction. Its friendly content source is `assets/content/hidden-details.json`; `assets/scripts/features/hidden-details.js` is generated by the external hidden-narrative authoring workflow. Treat both files as a pair and do not hand-edit only the generated JavaScript.
|
||||||
|
|
||||||
|
The Resource Loader at `/rl/` uses the same-origin `/api/resource-loader` backend as its sole metadata source, with ETags protecting concurrent edits. There is no browser metadata fallback. Generated cover thumbnails use `/api/resource-loader/thumbnails/:id` and are removed from local storage after upload. IndexedDB under `zxh-resource-loader` retains only imported PDF binaries on the device where they were added. JSON exports contain metadata and reading sessions, not PDF binaries. Its page code lives in `assets/scripts/pages/resource-loader.js`; the separately testable schema/progress/sync operations live in `resource-loader-model.js` and run with `make test-js`.
|
||||||
|
|
||||||
|
## Authoring service
|
||||||
|
|
||||||
|
The browser authoring UI is a separate sibling repository at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/home/zaine/master-folder/org-platform/authoring-service
|
||||||
|
```
|
||||||
|
|
||||||
|
It creates and edits the normal source files described above, stores Lima uploads under `assets/images/hzone/YYYY/MM/`, and enqueues site builds after saves. It is available locally at `http://127.0.0.1:8765` and, behind the production reverse proxy, at `https://author.zainezq.com`.
|
||||||
|
|
||||||
|
The Makefile delegates these commands to that repository:
|
||||||
|
|
||||||
|
- `make author` / `make author-start`
|
||||||
|
- `make author-install`
|
||||||
|
- `make author-stop`
|
||||||
|
- `make author-restart`
|
||||||
|
- `make author-health`
|
||||||
|
- `make author-status`
|
||||||
|
- `make author-diagnostics`
|
||||||
|
- `make author-logs`
|
||||||
|
|
||||||
|
They depend on the sibling repository existing at the hard-coded path and use its user systemd service.
|
||||||
|
|
||||||
|
## Testing and verification
|
||||||
|
|
||||||
|
Run the publishing tests:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make test-elisp
|
||||||
|
```
|
||||||
|
|
||||||
|
For a documentation/content-only change, a dry run validates package loading and generated inputs without rewriting all HTML:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SITE_DRY=1 emacs -Q --script build-site.el
|
||||||
|
```
|
||||||
|
|
||||||
|
For build or frontend changes, use a clean build, generate the search index, serve `output/`, and inspect the relevant pages in a browser:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SITE_CLEAN=1 emacs -Q --script build-site.el
|
||||||
|
.venv/bin/python search-index-json.py
|
||||||
|
python3 -m http.server 8000 --directory output
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify at least:
|
||||||
|
|
||||||
|
- the home page and global navigation;
|
||||||
|
- a blog/post with a table of contents, tags, sidenotes, and comments as applicable;
|
||||||
|
- the relevant special page under `home/` or `play/`;
|
||||||
|
- search results and keyboard navigation;
|
||||||
|
- browser console/network failures;
|
||||||
|
- the generated sitemap and tag/category pages.
|
||||||
|
|
||||||
|
## Deployment and CI
|
||||||
|
|
||||||
|
`.gitea/workflows/build.yml` runs on pushes to `main`, nightly at midnight, and manual dispatch. Push events that only change the hidden-details JSON/generated JS pair are ignored to prevent a build loop.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Git as Gitea main
|
||||||
|
participant Runner as org-web runner
|
||||||
|
participant Build as Site build
|
||||||
|
participant Monitor as PowerShell monitor
|
||||||
|
Git->>Runner: push, schedule, or manual dispatch
|
||||||
|
opt scheduled or manual
|
||||||
|
Runner->>Git: commit and push hidden-detail changes if present
|
||||||
|
end
|
||||||
|
Runner->>Build: SITE_CLEAN=1 make
|
||||||
|
Build-->>Runner: static output and logs
|
||||||
|
Runner->>Monitor: inspect result and authoring tests
|
||||||
|
Monitor-->>Runner: Discord notification
|
||||||
|
```
|
||||||
|
|
||||||
|
The runner is host-specific. It expects:
|
||||||
|
|
||||||
|
- the checkout at `/home/zaine/master-folder/org-platform/org_web`;
|
||||||
|
- output at that repository's `output/` directory;
|
||||||
|
- the permission helper referenced by the Makefile;
|
||||||
|
- PowerShell at `/snap/bin/pwsh`;
|
||||||
|
- the sibling authoring-service repository;
|
||||||
|
- Gitea/Discord credentials supplied as Actions secrets.
|
||||||
|
|
||||||
|
The workflow's monitor writes diagnostics and uses `DISCORD_WEBHOOK_URL` and `API_KEY`. Never commit those values or other service credentials.
|
||||||
|
|
||||||
|
## Safe change checklist
|
||||||
|
|
||||||
|
1. Edit source content, modular assets, or `lisp/`; do not edit `output/` as the source of a fix.
|
||||||
|
2. Check whether the file is generated or a compatibility wrapper before changing it.
|
||||||
|
3. Add new globally loaded assets to `lisp/build-assets.el`; keep page-only behavior scoped to its page when possible.
|
||||||
|
4. Preserve stable `#+SLUG` values on comment-enabled pages.
|
||||||
|
5. Run `make test-elisp` and the smallest representative build/browser checks for the change.
|
||||||
|
6. Review `git status` carefully: builds intentionally update generated Org files and manifests, while local logs and caches may also change.
|
||||||
|
7. Commit only the intended source and generated artifacts required by the workflow.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### A build skips files that are missing from `output/`
|
||||||
|
|
||||||
|
Run with `SITE_CLEAN=1`. The normal integrity check catches a missing asset directory or entry page, but it does not validate every generated page.
|
||||||
|
|
||||||
|
### `htmlize` cannot be installed
|
||||||
|
|
||||||
|
The first build needs access to MELPA/ELPA unless `.packages/` already contains it. Restore network access or provide the cached package; the build cannot continue without syntax-highlighting support.
|
||||||
|
|
||||||
|
### Lima Markdown fails to publish
|
||||||
|
|
||||||
|
Confirm `pandoc` is installed and available on `PATH`. Plain Org publishing does not require Pandoc.
|
||||||
|
|
||||||
|
### Search is unavailable
|
||||||
|
|
||||||
|
Confirm `output/search-index.json` exists and the site is served over HTTP. Run `make search` after publishing HTML.
|
||||||
|
|
||||||
|
### Interactive data does not load locally
|
||||||
|
|
||||||
|
Static file serving does not provide `/api/...`. Run/proxy the relevant external services, or expect those features to show their empty/error state.
|
||||||
|
|
||||||
|
### The full `make` command fails outside the production host
|
||||||
|
|
||||||
|
`make` runs a hard-coded permission helper with `sudo`. Use `make build` followed by `make search`, or the portable commands above, on another machine.
|
||||||
|
|||||||
50
assets/content/comment-pages.json
Normal file → Executable file
@@ -379,11 +379,61 @@
|
|||||||
"title": "Working on Eid day",
|
"title": "Working on Eid day",
|
||||||
"url": "/blogs/2026/05-may/working-on-eid-day-27-05-26.html"
|
"url": "/blogs/2026/05-may/working-on-eid-day-27-05-26.html"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"slug": "14-06-26-week-review",
|
||||||
|
"title": "[14-06-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/06-june/14-06-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "21-06-26-week-review",
|
||||||
|
"title": "[21-06-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/06-june/21-06-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "28-06-26-week-review",
|
||||||
|
"title": "[28-06-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/06-june/28-06-week-review.html"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"slug": "cleaning-up-the-repos",
|
"slug": "cleaning-up-the-repos",
|
||||||
"title": "Cleaning up the repos",
|
"title": "Cleaning up the repos",
|
||||||
"url": "/blogs/2026/06-june/cleaning-up-the-repos.html"
|
"url": "/blogs/2026/06-june/cleaning-up-the-repos.html"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"slug": "05-07-26-week-review",
|
||||||
|
"title": "[05-07-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/07-july/05-07-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "12-07-26-week-review",
|
||||||
|
"title": "[12-07-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/07-july/12-07-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "19-07-26-week-review",
|
||||||
|
"title": "[19-07-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/07-july/19-07-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "26-07-26-week-review",
|
||||||
|
"title": "[26-07-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/07-july/26-07-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "02-08-26-week-review",
|
||||||
|
"title": "[02-08-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/08-august/02-08-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "09-08-26-week-review",
|
||||||
|
"title": "[09-08-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/08-august/09-08-week-review.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "16-08-26-week-review",
|
||||||
|
"title": "[16-08-2026] - Weekly Review",
|
||||||
|
"url": "/blogs/2026/08-august/16-08-week-review.html"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"slug": "publish-pages",
|
"slug": "publish-pages",
|
||||||
"title": "How to publish pages using Org Publish",
|
"title": "How to publish pages using Org Publish",
|
||||||
|
|||||||
1785
assets/content/hidden-details.json
Normal file → Executable file
23
assets/content/resource-loader.json
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"vaults": [
|
||||||
|
{
|
||||||
|
"id": "vault-work",
|
||||||
|
"name": "Work",
|
||||||
|
"colour": "#526d82",
|
||||||
|
"folders": [],
|
||||||
|
"resources": [],
|
||||||
|
"createdAt": "2026-08-19T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-08-19T00:00:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vault-personal-study",
|
||||||
|
"name": "Personal Study",
|
||||||
|
"colour": "#9b6b43",
|
||||||
|
"folders": [],
|
||||||
|
"resources": [],
|
||||||
|
"createdAt": "2026-08-19T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-08-19T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
0
assets/icons/pen-icon.png
Normal file → Executable file
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
0
assets/images/blogs/2026-05-12-lima.jpg
Normal file → Executable file
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 123 KiB |
0
assets/images/blogs/2026-05-12-screenshot-2026-05-12-163301.png
Normal file → Executable file
|
Before Width: | Height: | Size: 860 KiB After Width: | Height: | Size: 860 KiB |
0
assets/images/blogs/2026-05-13-200.gif
Normal file → Executable file
|
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 343 KiB |
0
assets/images/blogs/2026-05-13-image.jpg
Normal file → Executable file
|
Before Width: | Height: | Size: 307 KiB After Width: | Height: | Size: 307 KiB |
0
assets/images/blogs/2026-05-13-poppadoms.jpg
Normal file → Executable file
|
Before Width: | Height: | Size: 217 KiB After Width: | Height: | Size: 217 KiB |
0
assets/images/blogs/2026-05-13-screenshot-2026-05-13-131807.png
Normal file → Executable file
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 78 KiB |
0
assets/images/blogs/2026-05-13-spinning-spin.webp
Normal file → Executable file
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
0
assets/images/blogs/2026-05-27-screenshot-2026-05-27-160508.png
Normal file → Executable file
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
0
assets/images/blogs/2026-06-04-screenshot-2026-06-04-143726.png
Normal file → Executable file
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
0
assets/images/career/2026-06-01-xss-attack.webp
Normal file → Executable file
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
0
assets/images/reviews/timesheets/2026-02-01-timesheet.png
Normal file → Executable file
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
0
assets/images/reviews/timesheets/2026-02-08-timesheet.png
Normal file → Executable file
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
0
assets/images/reviews/timesheets/2026-02-15-timesheet.png
Normal file → Executable file
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
0
assets/images/reviews/timesheets/2026-02-22-timesheet.png
Normal file → Executable file
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
0
assets/images/reviews/timesheets/2026-03-01-timesheet.png
Normal file → Executable file
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
0
assets/images/reviews/timesheets/2026-03-08-timesheet.png
Normal file → Executable file
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
0
assets/images/reviews/timesheets/2026-05-10-timesheet.png
Normal file → Executable file
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
0
assets/images/reviews/timesheets/2026-05-17-timesheet.png
Normal file → Executable file
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 51 KiB |
0
assets/images/reviews/timesheets/2026-05-24-timesheet.png
Normal file → Executable file
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
0
assets/images/reviews/timesheets/2026-05-31-timesheet.png
Normal file → Executable file
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
0
assets/scripts/application/theme-switcher.js
Normal file → Executable file
@@ -1,7 +0,0 @@
|
|||||||
(function () {
|
|
||||||
"use strict";
|
|
||||||
var script = document.createElement("script");
|
|
||||||
script.src = "/assets/scripts/pages/ash-below-lake.js";
|
|
||||||
script.defer = true;
|
|
||||||
document.head.appendChild(script);
|
|
||||||
}());
|
|
||||||
0
assets/scripts/features/comments.js
Normal file → Executable file
39
assets/scripts/features/hidden-details.js
Normal file → Executable file
@@ -35,7 +35,13 @@
|
|||||||
|
|
||||||
const familyLayers = [];
|
const familyLayers = [];
|
||||||
|
|
||||||
const details = [];
|
const details = [
|
||||||
|
"Keep one small appointment with yourself today.",
|
||||||
|
"Leave one kindness prepared for tomorrow-you.",
|
||||||
|
"Discipline can speak in a calm voice.",
|
||||||
|
"When the task feels heavy, remember the life it is trying to make possible.",
|
||||||
|
"Pause here: you are allowed to be proud of the person who kept returning."
|
||||||
|
];
|
||||||
|
|
||||||
const poems = [];
|
const poems = [];
|
||||||
|
|
||||||
@@ -44,11 +50,36 @@
|
|||||||
const nightMessages = [];
|
const nightMessages = [];
|
||||||
|
|
||||||
const lore = {
|
const lore = {
|
||||||
"quotes": [],
|
"quotes": [
|
||||||
|
"14/07/26 - big demo meeting with CEO tomorrow !!",
|
||||||
|
"Readiness often arrives ten minutes after you begin.",
|
||||||
|
"Rest is not a reward for becoming exhausted. It is maintenance for the person doing the becoming.",
|
||||||
|
"Before the world starts making requests, give your own life a few undivided minutes.",
|
||||||
|
"Every meaningful yes needs a few well-placed noes around it.",
|
||||||
|
"Motivation is weather. Build a shelter sturdy enough to keep going when the sky changes.",
|
||||||
|
"Do not design only for your best days. Create a minimum version of the habit that even a tired day can hold.",
|
||||||
|
"No repetition is wasted just because nobody applauded it.",
|
||||||
|
"Look backwards long enough to notice the ground you have already crossed.",
|
||||||
|
"Use the voice that helps you stand up again.",
|
||||||
|
"Sometimes courage is simply opening the document again.",
|
||||||
|
"When resistance feels enormous, promise only five minutes. The aim is not to finish; it is to make starting feel safe again.",
|
||||||
|
"If the habit keeps failing, redesign the doorway: put the book on the pillow, the shoes by the door, the difficult task at the hour your mind is clearest.",
|
||||||
|
"The exciting beginning and the visible result are connected by ordinary days. Learn to respect the bridge.",
|
||||||
|
"Completion creates its own kind of energy. Close one tab, send one message, clear one surface, finish one small loop."
|
||||||
|
],
|
||||||
"conversations": [],
|
"conversations": [],
|
||||||
"journals": [],
|
"journals": [
|
||||||
|
"10/07/26 - surviving a heatwave whilst working :-)",
|
||||||
|
"You do not need the whole staircase today. Name the next honest step, make it small enough to begin, and let motion teach you what planning cannot.",
|
||||||
|
"Replace ‘I am lazy’ with evidence: what drained me, what helped me, and what is the smallest change I can test tomorrow? Curiosity builds what shame only bruises.",
|
||||||
|
"A missed day is data, not destiny. Return before the story in your head turns one pause into an identity."
|
||||||
|
],
|
||||||
"warnings": [],
|
"warnings": [],
|
||||||
"dreams": [],
|
"dreams": [
|
||||||
|
"The path was hidden, but the compass still answered: values before mood, direction before speed, one careful footfall before certainty.",
|
||||||
|
"Long after inspiration went home, a single workshop light stayed on. Inside, patience was sanding rough effort into skill.",
|
||||||
|
"For a long time the seed had nothing visible to show. It was still becoming roots."
|
||||||
|
],
|
||||||
"cassettes": [],
|
"cassettes": [],
|
||||||
"fakeUsers": [],
|
"fakeUsers": [],
|
||||||
"seasonal": {},
|
"seasonal": {},
|
||||||
|
|||||||
0
assets/scripts/hidden-details.js
Normal file → Executable file
6
assets/scripts/pages/competency-status-board.js
Normal file → Executable file
@@ -236,5 +236,7 @@ async function loadBoard() {
|
|||||||
populateMobileControls(items);
|
populateMobileControls(items);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderLevelTabs();
|
if (document.getElementById("kanban-board")) {
|
||||||
loadBoard();
|
renderLevelTabs();
|
||||||
|
loadBoard();
|
||||||
|
}
|
||||||
|
|||||||
0
assets/scripts/pages/home-dashboard.js
Normal file → Executable file
0
assets/scripts/pages/notes.js
Normal file → Executable file
406
assets/scripts/pages/play.js
Normal file → Executable file
@@ -14,7 +14,7 @@
|
|||||||
const root = $(".play-root[data-play-page]");
|
const root = $(".play-root[data-play-page]");
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
const page = root.dataset.playPage;
|
const page = root.dataset.playPage;
|
||||||
const inits = { hub, memory, constellation, poem, bookshelf, recipe, timeline, ink, terminal, study, sigil, rpg };
|
const inits = { hub, memory, constellation, poem, bookshelf, ink, terminal, study, sigil };
|
||||||
if (inits[page]) inits[page](root);
|
if (inits[page]) inits[page](root);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -256,63 +256,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function recipe(root) {
|
|
||||||
const wheel = $("[data-recipe-spin]", root);
|
|
||||||
const result = $("[data-recipe-result]", root);
|
|
||||||
const groups = [
|
|
||||||
["Rice", "Flatbread", "Roast potatoes", "Noodles"],
|
|
||||||
["Lemon chicken", "Spiced lentils", "Tomato eggs", "Pepper stew"],
|
|
||||||
["Cucumber salad", "Mint yoghurt", "Pickled onions", "Charred greens"],
|
|
||||||
["Serve with stories", "Eat outside", "Use the blue plates", "Make extra tea"]
|
|
||||||
];
|
|
||||||
let spin = 0;
|
|
||||||
let feast = 0;
|
|
||||||
wheel.addEventListener("click", () => {
|
|
||||||
spin += 540 + Math.floor(Math.random() * 540);
|
|
||||||
feast += 1;
|
|
||||||
wheel.style.transform = `rotate(${spin}deg)`;
|
|
||||||
result.innerHTML = groups.map((group) => `<li>${pick(group)}</li>`).join("") + (feast % 4 === 0 ? "<li>Bonus: someone gets the last crispy bit.</li>" : "");
|
|
||||||
});
|
|
||||||
wheel.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
function timeline(root) {
|
|
||||||
const items = [
|
|
||||||
{ year: "2022", text: "The personal web habit begins." },
|
|
||||||
{ year: "2025", text: "Org publishing becomes the main site engine." },
|
|
||||||
{ year: "2025", text: "Weekly reviews and career notes grow into a library." },
|
|
||||||
{ year: "2026", text: "Dashboards, services, and authoring tools join the site." },
|
|
||||||
{ year: "2026", text: "The play wing opens." }
|
|
||||||
];
|
|
||||||
const list = $("[data-timeline-list]", root);
|
|
||||||
const status = $("[data-timeline-status]", root);
|
|
||||||
const paradox = document.createElement("button");
|
|
||||||
paradox.type = "button";
|
|
||||||
paradox.textContent = "Paradox";
|
|
||||||
$("[data-timeline-shuffle]", root).insertAdjacentElement("afterend", paradox);
|
|
||||||
setupSortable(list, render, check);
|
|
||||||
$("[data-timeline-shuffle]", root).addEventListener("click", () => render(shuffle(items)));
|
|
||||||
paradox.addEventListener("click", () => render([...items].reverse()));
|
|
||||||
render(shuffle(items));
|
|
||||||
|
|
||||||
function render(source) {
|
|
||||||
list.innerHTML = "";
|
|
||||||
source.forEach((item, index) => {
|
|
||||||
const card = document.createElement("div");
|
|
||||||
card.className = "timeline-card";
|
|
||||||
card.draggable = true;
|
|
||||||
card.dataset.value = String(items.indexOf(item));
|
|
||||||
card.innerHTML = `<span>${item.year}</span><strong>${item.text}</strong>`;
|
|
||||||
list.appendChild(card);
|
|
||||||
});
|
|
||||||
check();
|
|
||||||
}
|
|
||||||
function check() {
|
|
||||||
const current = $$(".timeline-card", list).map((card) => Number(card.dataset.value));
|
|
||||||
status.textContent = current.every((value, index) => value === index) ? "Timeline restored" : "Arrange the cards";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ink(root) {
|
function ink(root) {
|
||||||
const canvas = $("[data-ink-canvas]", root);
|
const canvas = $("[data-ink-canvas]", root);
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
@@ -492,353 +435,6 @@
|
|||||||
draw();
|
draw();
|
||||||
}
|
}
|
||||||
|
|
||||||
function rpg(root) {
|
|
||||||
if (window.AshBelowLakeRpg) return window.AshBelowLakeRpg(root);
|
|
||||||
const canvas = $("[data-rpg-canvas]", root);
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
const els = {
|
|
||||||
speaker: $("[data-rpg-speaker]", root),
|
|
||||||
line: $("[data-rpg-line]", root),
|
|
||||||
room: $("[data-rpg-room]", root),
|
|
||||||
route: $("[data-rpg-route]", root),
|
|
||||||
hearts: $("[data-rpg-hearts]", root),
|
|
||||||
quests: $("[data-rpg-quests]", root),
|
|
||||||
inventory: $("[data-rpg-inventory]", root),
|
|
||||||
status: $("[data-rpg-status]", root)
|
|
||||||
};
|
|
||||||
const tile = 32;
|
|
||||||
const saveSlot = "hollow-archive";
|
|
||||||
const fallbackKey = "play:rpg:hollow-archive";
|
|
||||||
const items = {
|
|
||||||
lamp: { label: "Desk Lamp", room: "entrance", x: 10, y: 8, color: "#f2c94c" },
|
|
||||||
page: { label: "Loose Page", room: "stacks", x: 18, y: 5, color: "#f7efe0" },
|
|
||||||
key: { label: "Basement Key", room: "garden", x: 4, y: 11, color: "#d59b45" }
|
|
||||||
};
|
|
||||||
const npcs = {
|
|
||||||
archivist: { name: "Archivist", room: "entrance", x: 6, y: 6, color: "#b98bff" },
|
|
||||||
shade: { name: "Shy Shade", room: "stacks", x: 17, y: 9, color: "#6ed0d4" },
|
|
||||||
gate: { name: "Iron Door", room: "garden", x: 19, y: 10, color: "#8a8f98" }
|
|
||||||
};
|
|
||||||
const rooms = {
|
|
||||||
entrance: {
|
|
||||||
name: "Entrance",
|
|
||||||
floor: "#353029",
|
|
||||||
exits: [{ x: 22, y: 7, to: "stacks", px: 1, py: 7 }],
|
|
||||||
walls: rects([[0, 0, 24, 1], [0, 15, 24, 1], [0, 0, 1, 16], [23, 0, 1, 6], [23, 9, 1, 7], [8, 3, 1, 8], [14, 7, 5, 1]])
|
|
||||||
},
|
|
||||||
stacks: {
|
|
||||||
name: "Stacks",
|
|
||||||
floor: "#242d35",
|
|
||||||
exits: [{ x: 0, y: 7, to: "entrance", px: 22, py: 7 }, { x: 23, y: 12, to: "garden", px: 1, py: 12 }],
|
|
||||||
walls: rects([[0, 0, 24, 1], [0, 15, 24, 1], [0, 0, 1, 6], [0, 9, 1, 7], [23, 0, 1, 11], [23, 14, 1, 2], [4, 3, 2, 10], [10, 2, 2, 11], [16, 3, 2, 5]])
|
|
||||||
},
|
|
||||||
garden: {
|
|
||||||
name: "Moon Garden",
|
|
||||||
floor: "#21362e",
|
|
||||||
exits: [{ x: 0, y: 12, to: "stacks", px: 22, py: 12 }],
|
|
||||||
walls: rects([[0, 0, 24, 1], [0, 15, 24, 1], [0, 0, 1, 11], [0, 14, 1, 2], [23, 0, 1, 16], [7, 4, 10, 1], [7, 10, 1, 4], [13, 10, 1, 4]])
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let state = freshState();
|
|
||||||
let running = false;
|
|
||||||
|
|
||||||
function freshState() {
|
|
||||||
return {
|
|
||||||
room: "entrance",
|
|
||||||
player: { x: 3, y: 7, facing: "down" },
|
|
||||||
inventory: [],
|
|
||||||
flags: {},
|
|
||||||
route: "Undecided",
|
|
||||||
hearts: 3,
|
|
||||||
ending: null,
|
|
||||||
message: { speaker: "Archivist", line: "The archive waits. Find the lamp, help the shade, then decide what to do with the locked door." }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function rects(sources) {
|
|
||||||
const set = new Set();
|
|
||||||
sources.forEach(([x, y, w, h]) => {
|
|
||||||
for (let yy = y; yy < y + h; yy += 1) for (let xx = x; xx < x + w; xx += 1) set.add(`${xx},${yy}`);
|
|
||||||
});
|
|
||||||
return set;
|
|
||||||
}
|
|
||||||
|
|
||||||
function draw() {
|
|
||||||
const room = rooms[state.room];
|
|
||||||
ctx.fillStyle = "#14110e";
|
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
||||||
for (let y = 0; y < 16; y += 1) {
|
|
||||||
for (let x = 0; x < 24; x += 1) {
|
|
||||||
ctx.fillStyle = room.walls.has(`${x},${y}`) ? "#181716" : room.floor;
|
|
||||||
ctx.fillRect(x * tile, y * tile, tile, tile);
|
|
||||||
ctx.strokeStyle = "rgba(255,255,255,0.035)";
|
|
||||||
ctx.strokeRect(x * tile, y * tile, tile, tile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
room.exits.forEach((exit) => drawGlyph(exit.x, exit.y, "#c48a41", "door"));
|
|
||||||
Object.entries(items).forEach(([id, item]) => {
|
|
||||||
if (item.room === state.room && !state.inventory.includes(id)) drawGlyph(item.x, item.y, item.color, "item");
|
|
||||||
});
|
|
||||||
Object.values(npcs).forEach((npc) => {
|
|
||||||
if (npc.room === state.room) drawGlyph(npc.x, npc.y, npc.color, npc.name === "Iron Door" ? "doorNpc" : "npc");
|
|
||||||
});
|
|
||||||
drawPlayer();
|
|
||||||
renderHud();
|
|
||||||
requestAnimationFrame(draw);
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawGlyph(x, y, color, kind) {
|
|
||||||
const px = x * tile;
|
|
||||||
const py = y * tile;
|
|
||||||
ctx.fillStyle = color;
|
|
||||||
if (kind === "item") {
|
|
||||||
ctx.fillRect(px + 10, py + 10, 12, 12);
|
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.45)";
|
|
||||||
ctx.fillRect(px + 13, py + 7, 6, 6);
|
|
||||||
} else if (kind === "door" || kind === "doorNpc") {
|
|
||||||
ctx.fillRect(px + 7, py + 4, 18, 25);
|
|
||||||
ctx.fillStyle = "#211812";
|
|
||||||
ctx.fillRect(px + 20, py + 16, 3, 3);
|
|
||||||
} else {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(px + 16, py + 12, 9, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
ctx.fillRect(px + 8, py + 20, 16, 8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawPlayer() {
|
|
||||||
const px = state.player.x * tile;
|
|
||||||
const py = state.player.y * tile;
|
|
||||||
ctx.fillStyle = "#f35f5f";
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(px + 16, py + 7);
|
|
||||||
ctx.bezierCurveTo(px + 2, py + 2, px + 1, py + 22, px + 16, py + 28);
|
|
||||||
ctx.bezierCurveTo(px + 31, py + 22, px + 30, py + 2, px + 16, py + 7);
|
|
||||||
ctx.fill();
|
|
||||||
ctx.fillStyle = "#fff8e8";
|
|
||||||
ctx.fillRect(px + 11, py + 13, 4, 4);
|
|
||||||
ctx.fillRect(px + 18, py + 13, 4, 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderHud() {
|
|
||||||
els.speaker.textContent = state.message.speaker;
|
|
||||||
els.line.textContent = state.ending ? endingLine() : state.message.line;
|
|
||||||
els.room.textContent = rooms[state.room].name;
|
|
||||||
els.route.textContent = state.route;
|
|
||||||
els.hearts.textContent = String(state.hearts);
|
|
||||||
els.inventory.innerHTML = state.inventory.length
|
|
||||||
? state.inventory.map((id) => `<li>${items[id].label}</li>`).join("")
|
|
||||||
: "<li>Empty</li>";
|
|
||||||
const questRows = [
|
|
||||||
["Find a light", state.inventory.includes("lamp")],
|
|
||||||
["Return the loose page to the shade", state.flags.shadeHelped],
|
|
||||||
["Open, force, or leave the iron door", Boolean(state.ending)]
|
|
||||||
];
|
|
||||||
els.quests.innerHTML = questRows.map(([text, done]) => `<li class="${done ? "is-done" : ""}">${done ? "Done: " : ""}${text}</li>`).join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function move(dx, dy, facing) {
|
|
||||||
if (!running || state.ending) return;
|
|
||||||
state.player.facing = facing;
|
|
||||||
const nx = state.player.x + dx;
|
|
||||||
const ny = state.player.y + dy;
|
|
||||||
const room = rooms[state.room];
|
|
||||||
const exit = room.exits.find((candidate) => candidate.x === nx && candidate.y === ny);
|
|
||||||
if (exit) {
|
|
||||||
state.room = exit.to;
|
|
||||||
state.player.x = exit.px;
|
|
||||||
state.player.y = exit.py;
|
|
||||||
say("Narrator", `You enter ${rooms[state.room].name}.`);
|
|
||||||
autosave();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (nx < 0 || ny < 0 || nx > 23 || ny > 15 || room.walls.has(`${nx},${ny}`) || npcAt(nx, ny)) return;
|
|
||||||
state.player.x = nx;
|
|
||||||
state.player.y = ny;
|
|
||||||
const item = itemAt(nx, ny);
|
|
||||||
if (item) take(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
function act() {
|
|
||||||
if (!running) return start();
|
|
||||||
if (state.ending) return;
|
|
||||||
const front = inFront();
|
|
||||||
const npc = npcAt(front.x, front.y);
|
|
||||||
if (npc) talk(npc);
|
|
||||||
else {
|
|
||||||
const here = itemAt(state.player.x, state.player.y);
|
|
||||||
if (here) take(here);
|
|
||||||
else say("Narrator", "Dust moves in the light. Nothing asks to be changed here.");
|
|
||||||
}
|
|
||||||
autosave();
|
|
||||||
}
|
|
||||||
|
|
||||||
function inFront() {
|
|
||||||
const delta = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }[state.player.facing] || [0, 1];
|
|
||||||
return { x: state.player.x + delta[0], y: state.player.y + delta[1] };
|
|
||||||
}
|
|
||||||
|
|
||||||
function itemAt(x, y) {
|
|
||||||
return Object.keys(items).find((id) => {
|
|
||||||
const item = items[id];
|
|
||||||
return item.room === state.room && item.x === x && item.y === y && !state.inventory.includes(id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function npcAt(x, y) {
|
|
||||||
return Object.keys(npcs).find((id) => {
|
|
||||||
const npc = npcs[id];
|
|
||||||
return npc.room === state.room && npc.x === x && npc.y === y;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function take(id) {
|
|
||||||
state.inventory.push(id);
|
|
||||||
if (id === "lamp") state.route = "Gentle";
|
|
||||||
if (id === "key" && !state.flags.shadeHelped) state.route = "Power";
|
|
||||||
say("Found", `${items[id].label} joined your inventory.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function talk(id) {
|
|
||||||
if (id === "archivist") {
|
|
||||||
if (!state.inventory.includes("lamp")) say("Archivist", "Take the lamp from the lower desk. The stacks dislike being crossed in the dark.");
|
|
||||||
else if (!state.flags.shadeHelped) say("Archivist", "A loose page has gone missing. The quiet reader in the stacks knows where it belongs.");
|
|
||||||
else say("Archivist", "You have been kind to a forgotten page. The garden door will remember that.");
|
|
||||||
}
|
|
||||||
if (id === "shade") {
|
|
||||||
if (!state.inventory.includes("page")) say("Shy Shade", "I lost the page with my name on it. It fell somewhere nearby.");
|
|
||||||
else {
|
|
||||||
state.flags.shadeHelped = true;
|
|
||||||
state.inventory = state.inventory.filter((item) => item !== "page");
|
|
||||||
state.route = "Mercy";
|
|
||||||
say("Shy Shade", "You returned my page instead of keeping it. Take the honest route through the garden.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (id === "gate") {
|
|
||||||
if (state.flags.shadeHelped) end("mercy");
|
|
||||||
else if (state.inventory.includes("key")) end("power");
|
|
||||||
else end("quiet");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function end(kind) {
|
|
||||||
state.ending = kind;
|
|
||||||
state.route = kind === "mercy" ? "Mercy" : kind === "power" ? "Power" : "Quiet";
|
|
||||||
say("Ending", endingLine());
|
|
||||||
save();
|
|
||||||
}
|
|
||||||
|
|
||||||
function endingLine() {
|
|
||||||
if (state.ending === "mercy") return "Mercy ending: the iron door opens without a sound, and every returned page remembers your name.";
|
|
||||||
if (state.ending === "power") return "Power ending: the key turns, but the archive grows colder around the missing page.";
|
|
||||||
if (state.ending === "quiet") return "Quiet ending: you leave the locked door alone. Some mysteries stay intact.";
|
|
||||||
return state.message.line;
|
|
||||||
}
|
|
||||||
|
|
||||||
function say(speaker, line) {
|
|
||||||
state.message = { speaker, line };
|
|
||||||
}
|
|
||||||
|
|
||||||
function start() {
|
|
||||||
running = true;
|
|
||||||
say("Archivist", "Walk the archive. Speak gently, or take what you need. The route will notice.");
|
|
||||||
els.status.textContent = "Started. Progress autosaves after room changes and actions.";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
localStorage.setItem(fallbackKey, JSON.stringify(state));
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/play/rpg/save/${saveSlot}`, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ payload: state })
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error(`Save failed: ${response.status}`);
|
|
||||||
els.status.textContent = "Saved to backend.";
|
|
||||||
} catch (_error) {
|
|
||||||
els.status.textContent = "Saved locally. Backend save API was not reachable.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/play/rpg/save/${saveSlot}`);
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
state = normalizeState(data.payload);
|
|
||||||
running = true;
|
|
||||||
els.status.textContent = "Loaded from backend.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (_error) {
|
|
||||||
/* Fall through to local save. */
|
|
||||||
}
|
|
||||||
const local = localStorage.getItem(fallbackKey);
|
|
||||||
if (local) {
|
|
||||||
state = normalizeState(JSON.parse(local));
|
|
||||||
running = true;
|
|
||||||
els.status.textContent = "Loaded local save.";
|
|
||||||
} else {
|
|
||||||
els.status.textContent = "No save found.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeState(candidate) {
|
|
||||||
return Object.assign(freshState(), candidate || {}, {
|
|
||||||
player: Object.assign(freshState().player, (candidate && candidate.player) || {}),
|
|
||||||
inventory: Array.isArray(candidate && candidate.inventory) ? candidate.inventory.filter((id) => items[id]) : [],
|
|
||||||
flags: Object.assign({}, (candidate && candidate.flags) || {}),
|
|
||||||
message: Object.assign(freshState().message, (candidate && candidate.message) || {})
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function autosave() {
|
|
||||||
save();
|
|
||||||
}
|
|
||||||
|
|
||||||
$("[data-rpg-start]", root).addEventListener("click", start);
|
|
||||||
$("[data-rpg-save]", root).addEventListener("click", save);
|
|
||||||
$("[data-rpg-load]", root).addEventListener("click", load);
|
|
||||||
$("[data-rpg-reset]", root).addEventListener("click", async () => {
|
|
||||||
state = freshState();
|
|
||||||
running = false;
|
|
||||||
localStorage.removeItem(fallbackKey);
|
|
||||||
try {
|
|
||||||
await fetch(`/api/play/rpg/save/${saveSlot}`, { method: "DELETE" });
|
|
||||||
els.status.textContent = "Reset and cleared backend save.";
|
|
||||||
} catch (_error) {
|
|
||||||
els.status.textContent = "Reset locally. Backend save API was not reachable.";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
$("[data-rpg-act]", root).addEventListener("click", act);
|
|
||||||
$$("[data-rpg-move]", root).forEach((button) => {
|
|
||||||
const moves = { up: [0, -1, "up"], down: [0, 1, "down"], left: [-1, 0, "left"], right: [1, 0, "right"] };
|
|
||||||
button.addEventListener("click", () => move(...moves[button.dataset.rpgMove]));
|
|
||||||
});
|
|
||||||
window.addEventListener("keydown", (event) => {
|
|
||||||
if (!root.isConnected) return;
|
|
||||||
const tag = event.target.tagName;
|
|
||||||
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
|
||||||
const keys = {
|
|
||||||
ArrowUp: [0, -1, "up"], w: [0, -1, "up"],
|
|
||||||
ArrowDown: [0, 1, "down"], s: [0, 1, "down"],
|
|
||||||
ArrowLeft: [-1, 0, "left"], a: [-1, 0, "left"],
|
|
||||||
ArrowRight: [1, 0, "right"], d: [1, 0, "right"]
|
|
||||||
};
|
|
||||||
if (keys[event.key]) {
|
|
||||||
event.preventDefault();
|
|
||||||
move(...keys[event.key]);
|
|
||||||
}
|
|
||||||
if (event.key === " " || event.key === "Enter") {
|
|
||||||
event.preventDefault();
|
|
||||||
act();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
draw();
|
|
||||||
load();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupSortable(container, _render, after) {
|
function setupSortable(container, _render, after) {
|
||||||
let dragged = null;
|
let dragged = null;
|
||||||
container.addEventListener("dragstart", (event) => {
|
container.addEventListener("dragstart", (event) => {
|
||||||
|
|||||||
261
assets/scripts/pages/resource-loader-model.js
Executable file
@@ -0,0 +1,261 @@
|
|||||||
|
(function (root, factory) {
|
||||||
|
const api = factory();
|
||||||
|
if (typeof module === "object" && module.exports) module.exports = api;
|
||||||
|
root.ResourceLoaderModel = api;
|
||||||
|
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const SCHEMA_VERSION = 1;
|
||||||
|
const TYPES = ["book", "article", "video"];
|
||||||
|
const FORMATS = ["physical", "pdf", "web"];
|
||||||
|
const STATUSES = ["backlog", "shelf", "active", "completed", "archived"];
|
||||||
|
|
||||||
|
function id() {
|
||||||
|
if (globalThis.crypto && typeof globalThis.crypto.randomUUID === "function") {
|
||||||
|
return globalThis.crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `rl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function now() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clone(value) {
|
||||||
|
return JSON.parse(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyVault(name, colour) {
|
||||||
|
const stamp = now();
|
||||||
|
return {
|
||||||
|
id: id(),
|
||||||
|
name,
|
||||||
|
colour: colour || "#9b6b43",
|
||||||
|
folders: [],
|
||||||
|
resources: [],
|
||||||
|
createdAt: stamp,
|
||||||
|
updatedAt: stamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSeed() {
|
||||||
|
return {
|
||||||
|
schemaVersion: SCHEMA_VERSION,
|
||||||
|
vaults: [emptyVault("Work", "#526d82"), emptyVault("Personal Study", "#9b6b43")],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateLibrary(input) {
|
||||||
|
assert(input && typeof input === "object" && !Array.isArray(input), "The JSON root must be an object.");
|
||||||
|
assert(input.schemaVersion === SCHEMA_VERSION, `Unsupported schemaVersion; expected ${SCHEMA_VERSION}.`);
|
||||||
|
assert(Array.isArray(input.vaults), "vaults must be an array.");
|
||||||
|
|
||||||
|
const vaultIds = new Set();
|
||||||
|
input.vaults.forEach((vault, vaultIndex) => {
|
||||||
|
assert(vault && typeof vault === "object", `Vault ${vaultIndex + 1} is invalid.`);
|
||||||
|
assert(typeof vault.id === "string" && vault.id, `Vault ${vaultIndex + 1} needs an id.`);
|
||||||
|
assert(!vaultIds.has(vault.id), `Duplicate vault id: ${vault.id}`);
|
||||||
|
vaultIds.add(vault.id);
|
||||||
|
assert(typeof vault.name === "string" && vault.name.trim(), `Vault ${vaultIndex + 1} needs a name.`);
|
||||||
|
assert(Array.isArray(vault.folders), `folders must be an array in ${vault.name}.`);
|
||||||
|
assert(Array.isArray(vault.resources), `resources must be an array in ${vault.name}.`);
|
||||||
|
|
||||||
|
const folderIds = new Set();
|
||||||
|
vault.folders.forEach((folder) => {
|
||||||
|
assert(folder && typeof folder.id === "string" && folder.id, `A folder in ${vault.name} needs an id.`);
|
||||||
|
assert(!folderIds.has(folder.id), `Duplicate folder id: ${folder.id}`);
|
||||||
|
folderIds.add(folder.id);
|
||||||
|
assert(typeof folder.name === "string" && folder.name.trim(), `A folder in ${vault.name} needs a name.`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const resourceIds = new Set();
|
||||||
|
vault.resources.forEach((resource) => {
|
||||||
|
assert(resource && typeof resource.id === "string" && resource.id, `A resource in ${vault.name} needs an id.`);
|
||||||
|
assert(!resourceIds.has(resource.id), `Duplicate resource id: ${resource.id}`);
|
||||||
|
resourceIds.add(resource.id);
|
||||||
|
assert(typeof resource.title === "string" && resource.title.trim(), "Every resource needs a title.");
|
||||||
|
assert(TYPES.includes(resource.type), `Invalid resource type: ${resource.type}`);
|
||||||
|
assert(FORMATS.includes(resource.format), `Invalid resource format: ${resource.format}`);
|
||||||
|
assert(STATUSES.includes(resource.status), `Invalid resource status: ${resource.status}`);
|
||||||
|
assert(!resource.folderId || folderIds.has(resource.folderId), `Resource ${resource.title} refers to a missing folder.`);
|
||||||
|
assert(!resource.sessions || Array.isArray(resource.sessions), `sessions must be an array for ${resource.title}.`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normaliseLibrary(input) {
|
||||||
|
validateLibrary(input);
|
||||||
|
const data = clone(input);
|
||||||
|
data.vaults.forEach((vault) => {
|
||||||
|
vault.colour = vault.colour || "#9b6b43";
|
||||||
|
vault.createdAt = vault.createdAt || now();
|
||||||
|
vault.updatedAt = vault.updatedAt || vault.createdAt;
|
||||||
|
vault.folders.forEach((folder) => {
|
||||||
|
folder.createdAt = folder.createdAt || now();
|
||||||
|
folder.updatedAt = folder.updatedAt || folder.createdAt;
|
||||||
|
});
|
||||||
|
vault.resources.forEach((resource) => {
|
||||||
|
resource.creators = Array.isArray(resource.creators) ? resource.creators : [];
|
||||||
|
resource.tags = Array.isArray(resource.tags) ? resource.tags : [];
|
||||||
|
resource.sessions = Array.isArray(resource.sessions) ? resource.sessions : [];
|
||||||
|
resource.folderId = resource.folderId || null;
|
||||||
|
resource.createdAt = resource.createdAt || now();
|
||||||
|
resource.updatedAt = resource.updatedAt || resource.createdAt;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeById(existing, incoming) {
|
||||||
|
const map = new Map(existing.map((item) => [item.id, clone(item)]));
|
||||||
|
incoming.forEach((item) => map.set(item.id, clone(item)));
|
||||||
|
return Array.from(map.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeLibraries(current, incoming) {
|
||||||
|
const base = normaliseLibrary(current);
|
||||||
|
const addition = normaliseLibrary(incoming);
|
||||||
|
const vaultMap = new Map(base.vaults.map((vault) => [vault.id, vault]));
|
||||||
|
|
||||||
|
addition.vaults.forEach((newVault) => {
|
||||||
|
const oldVault = vaultMap.get(newVault.id);
|
||||||
|
if (!oldVault) {
|
||||||
|
vaultMap.set(newVault.id, newVault);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vaultMap.set(newVault.id, {
|
||||||
|
...oldVault,
|
||||||
|
...newVault,
|
||||||
|
folders: mergeById(oldVault.folders, newVault.folders),
|
||||||
|
resources: mergeById(oldVault.resources, newVault.resources),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const merged = { schemaVersion: SCHEMA_VERSION, vaults: Array.from(vaultMap.values()) };
|
||||||
|
return normaliseLibrary(merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
function newestByTimestamp(remote, local) {
|
||||||
|
const remoteStamp = String(remote.updatedAt || remote.createdAt || "");
|
||||||
|
const localStamp = String(local.updatedAt || local.createdAt || "");
|
||||||
|
return clone(localStamp >= remoteStamp ? local : remote);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeSyncItems(remoteItems, localItems, mergeMatch) {
|
||||||
|
const items = new Map(remoteItems.map((item) => [item.id, clone(item)]));
|
||||||
|
localItems.forEach((local) => {
|
||||||
|
const remote = items.get(local.id);
|
||||||
|
items.set(local.id, remote ? mergeMatch(remote, local) : clone(local));
|
||||||
|
});
|
||||||
|
return Array.from(items.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeForSync(remoteLibrary, localLibrary) {
|
||||||
|
const remote = normaliseLibrary(remoteLibrary);
|
||||||
|
const local = normaliseLibrary(localLibrary);
|
||||||
|
const vaults = mergeSyncItems(remote.vaults, local.vaults, (remoteVault, localVault) => {
|
||||||
|
const vault = newestByTimestamp(remoteVault, localVault);
|
||||||
|
vault.folders = mergeSyncItems(remoteVault.folders, localVault.folders, newestByTimestamp);
|
||||||
|
vault.resources = mergeSyncItems(remoteVault.resources, localVault.resources, (remoteResource, localResource) => {
|
||||||
|
const resource = newestByTimestamp(remoteResource, localResource);
|
||||||
|
resource.sessions = mergeSyncItems(remoteResource.sessions || [], localResource.sessions || [], newestByTimestamp);
|
||||||
|
return resource;
|
||||||
|
});
|
||||||
|
return vault;
|
||||||
|
});
|
||||||
|
return normaliseLibrary({ schemaVersion: SCHEMA_VERSION, vaults });
|
||||||
|
}
|
||||||
|
|
||||||
|
function progressFor(resource) {
|
||||||
|
const sessions = Array.isArray(resource.sessions) ? resource.sessions : [];
|
||||||
|
const furthest = sessions.reduce((maximum, session) => {
|
||||||
|
const value = Number(session.end);
|
||||||
|
return Number.isFinite(value) ? Math.max(maximum, value) : maximum;
|
||||||
|
}, 0);
|
||||||
|
let total = 100;
|
||||||
|
if (resource.type === "book") total = Number(resource.pageCount) || 0;
|
||||||
|
if (resource.type === "video") total = Number(resource.durationMinutes) || 0;
|
||||||
|
if (!total) return { current: furthest, total: 0, percent: resource.status === "completed" ? 100 : 0 };
|
||||||
|
const percent = Math.max(0, Math.min(100, Math.round((furthest / total) * 100)));
|
||||||
|
return { current: furthest, total, percent: resource.status === "completed" ? 100 : percent };
|
||||||
|
}
|
||||||
|
|
||||||
|
function addVault(library, name, colour) {
|
||||||
|
const data = normaliseLibrary(library);
|
||||||
|
data.vaults.push(emptyVault(name.trim(), colour));
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renameVault(library, vaultId, name) {
|
||||||
|
const data = normaliseLibrary(library);
|
||||||
|
const vault = data.vaults.find((item) => item.id === vaultId);
|
||||||
|
assert(vault, "Vault not found.");
|
||||||
|
vault.name = name.trim();
|
||||||
|
vault.updatedAt = now();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteVault(library, vaultId) {
|
||||||
|
const data = normaliseLibrary(library);
|
||||||
|
assert(data.vaults.length > 1, "Keep at least one vault.");
|
||||||
|
data.vaults = data.vaults.filter((vault) => vault.id !== vaultId);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFolder(library, vaultId, name) {
|
||||||
|
const data = normaliseLibrary(library);
|
||||||
|
const vault = data.vaults.find((item) => item.id === vaultId);
|
||||||
|
assert(vault, "Vault not found.");
|
||||||
|
const stamp = now();
|
||||||
|
vault.folders.push({ id: id(), name: name.trim(), createdAt: stamp, updatedAt: stamp });
|
||||||
|
vault.updatedAt = stamp;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteFolder(library, vaultId, folderId) {
|
||||||
|
const data = normaliseLibrary(library);
|
||||||
|
const vault = data.vaults.find((item) => item.id === vaultId);
|
||||||
|
assert(vault, "Vault not found.");
|
||||||
|
vault.folders = vault.folders.filter((folder) => folder.id !== folderId);
|
||||||
|
vault.resources.forEach((resource) => {
|
||||||
|
if (resource.folderId === folderId) resource.folderId = null;
|
||||||
|
});
|
||||||
|
vault.updatedAt = now();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serialise(library) {
|
||||||
|
const data = normaliseLibrary(library);
|
||||||
|
data.exportedAt = now();
|
||||||
|
data.vaults.forEach((vault) => vault.resources.forEach((resource) => {
|
||||||
|
if (resource.attachmentId) resource.attachmentState = "reattach-required-after-import";
|
||||||
|
}));
|
||||||
|
return JSON.stringify(data, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
SCHEMA_VERSION,
|
||||||
|
TYPES,
|
||||||
|
FORMATS,
|
||||||
|
STATUSES,
|
||||||
|
id,
|
||||||
|
now,
|
||||||
|
createSeed,
|
||||||
|
validateLibrary,
|
||||||
|
normaliseLibrary,
|
||||||
|
mergeLibraries,
|
||||||
|
mergeForSync,
|
||||||
|
progressFor,
|
||||||
|
addVault,
|
||||||
|
renameVault,
|
||||||
|
deleteVault,
|
||||||
|
addFolder,
|
||||||
|
deleteFolder,
|
||||||
|
serialise,
|
||||||
|
};
|
||||||
|
});
|
||||||
908
assets/scripts/pages/resource-loader.js
Executable file
@@ -0,0 +1,908 @@
|
|||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const root = document.getElementById("resource-loader");
|
||||||
|
const Model = globalThis.ResourceLoaderModel;
|
||||||
|
if (!root || !Model) return;
|
||||||
|
|
||||||
|
const DB_NAME = "zxh-resource-loader";
|
||||||
|
const DB_VERSION = 2;
|
||||||
|
const ATTACHMENT_STORE = "attachments";
|
||||||
|
const API_URL = "/api/resource-loader";
|
||||||
|
const $ = (selector) => document.querySelector(selector);
|
||||||
|
const $$ = (selector) => Array.from(document.querySelectorAll(selector));
|
||||||
|
const state = {
|
||||||
|
db: null,
|
||||||
|
library: null,
|
||||||
|
vaultId: null,
|
||||||
|
folderId: "all",
|
||||||
|
view: localStorage.getItem("rl-view") || "list",
|
||||||
|
importData: null,
|
||||||
|
nameAction: null,
|
||||||
|
pendingImport: null,
|
||||||
|
pdfUrls: new Map(),
|
||||||
|
thumbnailUrls: new Map(),
|
||||||
|
uploadedThumbnails: new Set(),
|
||||||
|
serverEtag: null,
|
||||||
|
syncQueue: Promise.resolve(),
|
||||||
|
eventsBound: false,
|
||||||
|
};
|
||||||
|
let pdfJsPromise;
|
||||||
|
|
||||||
|
const STATUS_GROUPS = [
|
||||||
|
{ id: "active", label: "In progress", description: "Resources you are actively reading or watching." },
|
||||||
|
{ id: "backlog", label: "Backlog", description: "Resources you intend to start later." },
|
||||||
|
{ id: "shelf", label: "On the shelf", description: "Resources kept close at hand, but not currently active." },
|
||||||
|
{ id: "completed", label: "Completed", description: "Resources you have finished." },
|
||||||
|
{ id: "archived", label: "Archived", description: "Resources retained outside your current library flow." },
|
||||||
|
];
|
||||||
|
|
||||||
|
const els = {
|
||||||
|
vaultSelect: $("#rl-vault-select"),
|
||||||
|
folderList: $("#rl-folder-list"),
|
||||||
|
resourceList: $("#rl-resource-list"),
|
||||||
|
contextLabel: $("#rl-context-label"),
|
||||||
|
search: $("#rl-search"),
|
||||||
|
typeFilter: $("#rl-filter-type"),
|
||||||
|
statusFilter: $("#rl-filter-status"),
|
||||||
|
tagFilter: $("#rl-filter-tag"),
|
||||||
|
sort: $("#rl-sort"),
|
||||||
|
resourceDialog: $("#rl-resource-dialog"),
|
||||||
|
resourceForm: $("#rl-resource-form"),
|
||||||
|
sessionDialog: $("#rl-session-dialog"),
|
||||||
|
nameDialog: $("#rl-name-dialog"),
|
||||||
|
vaultDialog: $("#rl-vault-dialog"),
|
||||||
|
importDialog: $("#rl-import-dialog"),
|
||||||
|
storageDialog: $("#rl-storage-dialog"),
|
||||||
|
pdfInput: $("#rl-pdf-input"),
|
||||||
|
jsonInput: $("#rl-json-input"),
|
||||||
|
loading: $("#rl-loading"),
|
||||||
|
toast: $("#rl-toast"),
|
||||||
|
syncStatus: $("#rl-sync-status"),
|
||||||
|
};
|
||||||
|
|
||||||
|
function openDatabase() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
const db = request.result;
|
||||||
|
if (db.objectStoreNames.contains("metadata")) db.deleteObjectStore("metadata");
|
||||||
|
if (!db.objectStoreNames.contains(ATTACHMENT_STORE)) db.createObjectStore(ATTACHMENT_STORE, { keyPath: "id" });
|
||||||
|
};
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(storeName, mode, operation) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = state.db.transaction(storeName, mode);
|
||||||
|
const store = transaction.objectStore(storeName);
|
||||||
|
const result = operation(store);
|
||||||
|
transaction.oncomplete = () => resolve(result && result.result);
|
||||||
|
transaction.onerror = () => reject(transaction.error || (result && result.error));
|
||||||
|
transaction.onabort = () => reject(transaction.error || new Error("Storage operation was aborted."));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAttachment(id) { return id ? request(ATTACHMENT_STORE, "readonly", (store) => store.get(id)) : Promise.resolve(null); }
|
||||||
|
function putAttachment(record) { return request(ATTACHMENT_STORE, "readwrite", (store) => store.put(record)); }
|
||||||
|
function deleteAttachment(id) { return id ? request(ATTACHMENT_STORE, "readwrite", (store) => store.delete(id)) : Promise.resolve(); }
|
||||||
|
function deleteStoredThumbnails() {
|
||||||
|
return request(ATTACHMENT_STORE, "readwrite", (store) => {
|
||||||
|
const cursorRequest = store.openCursor();
|
||||||
|
cursorRequest.onsuccess = () => {
|
||||||
|
const cursor = cursorRequest.result;
|
||||||
|
if (!cursor) return;
|
||||||
|
if (cursor.value && cursor.value.kind === "thumbnail") cursor.delete();
|
||||||
|
cursor.continue();
|
||||||
|
};
|
||||||
|
return cursorRequest;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSyncStatus(status, label) {
|
||||||
|
if (!els.syncStatus) return;
|
||||||
|
els.syncStatus.dataset.state = status;
|
||||||
|
els.syncStatus.querySelector("small").textContent = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function responseError(response, prefix) {
|
||||||
|
let detail = "";
|
||||||
|
try {
|
||||||
|
const contentType = response.headers.get("Content-Type") || "";
|
||||||
|
if (contentType.includes("application/json")) {
|
||||||
|
const body = await response.json();
|
||||||
|
detail = body && body.message ? body.message : "";
|
||||||
|
} else {
|
||||||
|
detail = (await response.text()).trim().slice(0, 240);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
detail = "";
|
||||||
|
}
|
||||||
|
return new Error(`${prefix} (${response.status})${detail ? `: ${detail}` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchServerLibrary() {
|
||||||
|
const response = await fetch(API_URL, { cache: "no-store", credentials: "same-origin" });
|
||||||
|
if (response.status === 404) return null;
|
||||||
|
if (!response.ok) throw await responseError(response, "Server library request failed");
|
||||||
|
const library = Model.normaliseLibrary(await response.json());
|
||||||
|
state.serverEtag = response.headers.get("ETag");
|
||||||
|
return library;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadLocalThumbnails(library) {
|
||||||
|
const ids = Array.from(new Set(library.vaults.flatMap((vault) => vault.resources.map((resource) => resource.thumbnailId).filter(Boolean))));
|
||||||
|
await Promise.all(ids.map(async (id) => {
|
||||||
|
if (state.uploadedThumbnails.has(id)) return;
|
||||||
|
const attachment = await getAttachment(id);
|
||||||
|
if (!attachment || !attachment.blob || attachment.kind !== "thumbnail") return;
|
||||||
|
const response = await fetch(`${API_URL}/thumbnails/${encodeURIComponent(id)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "Content-Type": attachment.type || attachment.blob.type || "image/jpeg" },
|
||||||
|
body: attachment.blob,
|
||||||
|
});
|
||||||
|
if (!response.ok) throw await responseError(response, "Thumbnail upload failed");
|
||||||
|
await deleteAttachment(id);
|
||||||
|
state.uploadedThumbnails.add(id);
|
||||||
|
}));
|
||||||
|
await deleteStoredThumbnails();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushServerLibrary(library, allowMerge) {
|
||||||
|
setSyncStatus("syncing", "syncing…");
|
||||||
|
const headers = { "Content-Type": "application/json" };
|
||||||
|
if (state.serverEtag) headers["If-Match"] = state.serverEtag;
|
||||||
|
const response = await fetch(API_URL, {
|
||||||
|
method: "PUT",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(library),
|
||||||
|
});
|
||||||
|
if (response.status === 412 && allowMerge) {
|
||||||
|
const remote = await fetchServerLibrary();
|
||||||
|
const merged = Model.mergeForSync(remote, library);
|
||||||
|
state.library = merged;
|
||||||
|
if (state.eventsBound) render();
|
||||||
|
toast("Changes from another device were merged.");
|
||||||
|
return pushServerLibrary(merged, false);
|
||||||
|
}
|
||||||
|
if (!response.ok) throw await responseError(response, "Server save failed");
|
||||||
|
state.serverEtag = response.headers.get("ETag");
|
||||||
|
setSyncStatus("synced", "synced to server");
|
||||||
|
uploadLocalThumbnails(library).catch((error) => {
|
||||||
|
console.warn("Resource Loader metadata was saved, but a cover could not be uploaded.", error);
|
||||||
|
toast(`Saved to the server. Cover upload pending: ${error.message}`, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLibrary() {
|
||||||
|
state.syncQueue = state.syncQueue.catch(() => {}).then(async () => {
|
||||||
|
try {
|
||||||
|
await pushServerLibrary(Model.normaliseLibrary(state.library), true);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Resource Loader could not save to the server.", error);
|
||||||
|
setSyncStatus("error", "server unavailable");
|
||||||
|
toast("The change was not saved. Reloading the shared server library.", true);
|
||||||
|
try {
|
||||||
|
const remote = await fetchServerLibrary();
|
||||||
|
if (remote) {
|
||||||
|
state.library = remote;
|
||||||
|
if (state.eventsBound) render();
|
||||||
|
}
|
||||||
|
} catch (reloadError) {
|
||||||
|
console.warn("The authoritative server library could not be reloaded.", reloadError);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return state.syncQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLibrary() {
|
||||||
|
const remote = await fetchServerLibrary();
|
||||||
|
if (!remote) throw new Error("The server Resource Loader library has not been initialised.");
|
||||||
|
setSyncStatus("synced", "synced to server");
|
||||||
|
return remote;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentVault() {
|
||||||
|
return state.library.vaults.find((vault) => vault.id === state.vaultId) || state.library.vaults[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentResource(id) {
|
||||||
|
return currentVault().resources.find((resource) => resource.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value == null ? "" : value)
|
||||||
|
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """).replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleCase(value) {
|
||||||
|
return String(value || "").replace(/[-_]/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitList(value) {
|
||||||
|
return String(value || "").split(",").map((item) => item.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLoading(message) {
|
||||||
|
els.loading.querySelector("p").textContent = message || "Working…";
|
||||||
|
els.loading.classList.add("is-visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideLoading() { els.loading.classList.remove("is-visible"); }
|
||||||
|
|
||||||
|
let toastTimer;
|
||||||
|
function toast(message, error) {
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
els.toast.textContent = message;
|
||||||
|
els.toast.classList.toggle("is-error", Boolean(error));
|
||||||
|
els.toast.classList.add("is-visible");
|
||||||
|
toastTimer = setTimeout(() => els.toast.classList.remove("is-visible"), 3600);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDialog(dialog) {
|
||||||
|
if (dialog && dialog.open) dialog.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setValue(id, value) {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
if (element) element.value = value == null ? "" : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVaultSelect() {
|
||||||
|
const previous = state.vaultId;
|
||||||
|
els.vaultSelect.innerHTML = state.library.vaults.map((vault) =>
|
||||||
|
`<option value="${escapeHtml(vault.id)}">${escapeHtml(vault.name)}</option>`).join("");
|
||||||
|
state.vaultId = state.library.vaults.some((vault) => vault.id === previous) ? previous : state.library.vaults[0].id;
|
||||||
|
els.vaultSelect.value = state.vaultId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resourceCount(folderId) {
|
||||||
|
const resources = currentVault().resources;
|
||||||
|
if (folderId === "all") return resources.length;
|
||||||
|
if (folderId === "unfiled") return resources.filter((item) => !item.folderId).length;
|
||||||
|
return resources.filter((item) => item.folderId === folderId).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function folderRow(id, label, editable) {
|
||||||
|
return `<div class="rl-folder-row${state.folderId === id ? " is-active" : ""}" data-folder-row="${escapeHtml(id)}">
|
||||||
|
<button type="button" data-folder="${escapeHtml(id)}"><span>${escapeHtml(label)}</span><span>${resourceCount(id)}</span></button>
|
||||||
|
${editable ? `<button type="button" class="rl-folder-menu" data-folder-menu="${escapeHtml(id)}" aria-label="Manage ${escapeHtml(label)}">•••</button>` : ""}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFolders() {
|
||||||
|
const vault = currentVault();
|
||||||
|
if (!vault.folders.some((folder) => folder.id === state.folderId) && !["all", "unfiled"].includes(state.folderId)) state.folderId = "all";
|
||||||
|
els.folderList.innerHTML = folderRow("all", "All resources", false) + folderRow("unfiled", "Unfiled", false) +
|
||||||
|
'<div class="rl-folder-rule" aria-hidden="true"></div>' +
|
||||||
|
vault.folders.slice().sort((a, b) => a.name.localeCompare(b.name)).map((folder) => folderRow(folder.id, folder.name, true)).join("");
|
||||||
|
|
||||||
|
els.folderList.querySelectorAll("[data-folder]").forEach((button) => button.addEventListener("click", () => {
|
||||||
|
state.folderId = button.dataset.folder;
|
||||||
|
render();
|
||||||
|
}));
|
||||||
|
els.folderList.querySelectorAll("[data-folder-menu]").forEach((button) => button.addEventListener("click", () => manageFolder(button.dataset.folderMenu)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateTagFilter() {
|
||||||
|
const previous = els.tagFilter.value;
|
||||||
|
const tags = Array.from(new Set(currentVault().resources.flatMap((resource) => resource.tags || []))).sort((a, b) => a.localeCompare(b));
|
||||||
|
els.tagFilter.innerHTML = '<option value="">All tags</option>' + tags.map((tag) => `<option value="${escapeHtml(tag)}">${escapeHtml(tag)}</option>`).join("");
|
||||||
|
els.tagFilter.value = tags.includes(previous) ? previous : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredResources() {
|
||||||
|
const query = els.search.value.trim().toLocaleLowerCase();
|
||||||
|
let resources = currentVault().resources.filter((resource) => {
|
||||||
|
if (state.folderId === "unfiled" && resource.folderId) return false;
|
||||||
|
if (!["all", "unfiled"].includes(state.folderId) && resource.folderId !== state.folderId) return false;
|
||||||
|
if (els.typeFilter.value && resource.type !== els.typeFilter.value) return false;
|
||||||
|
if (els.statusFilter.value && resource.status !== els.statusFilter.value) return false;
|
||||||
|
if (els.tagFilter.value && !(resource.tags || []).includes(els.tagFilter.value)) return false;
|
||||||
|
const haystack = [resource.title, ...(resource.creators || []), ...(resource.tags || []), resource.description, resource.notes].join(" ").toLocaleLowerCase();
|
||||||
|
return !query || haystack.includes(query);
|
||||||
|
});
|
||||||
|
resources.sort((a, b) => {
|
||||||
|
if (els.sort.value === "title") return a.title.localeCompare(b.title);
|
||||||
|
if (els.sort.value === "progress") return Model.progressFor(b).percent - Model.progressFor(a).percent;
|
||||||
|
return String(b.updatedAt).localeCompare(String(a.updatedAt));
|
||||||
|
});
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function thumbnailUrl(id) {
|
||||||
|
if (!id) return null;
|
||||||
|
if (state.thumbnailUrls.has(id)) return state.thumbnailUrls.get(id);
|
||||||
|
const attachment = await getAttachment(id);
|
||||||
|
let blob = attachment && attachment.blob;
|
||||||
|
if (!blob) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/thumbnails/${encodeURIComponent(id)}`, { cache: "no-store", credentials: "same-origin" });
|
||||||
|
if (response.ok) blob = await response.blob();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Thumbnail ${id} is unavailable.`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!blob) return null;
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
state.thumbnailUrls.set(id, url);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardMarkup(resource) {
|
||||||
|
const progress = Model.progressFor(resource);
|
||||||
|
const initial = (resource.title || "?").trim().charAt(0).toUpperCase();
|
||||||
|
const creators = (resource.creators || []).join(", ") || "Unknown creator";
|
||||||
|
return `<article class="rl-resource-card rl-resource-card--${escapeHtml(resource.status)}" tabindex="0" role="button" data-resource-id="${escapeHtml(resource.id)}" aria-label="Open ${escapeHtml(resource.title)}">
|
||||||
|
<span class="rl-resource-cover" data-thumb="${escapeHtml(resource.thumbnailId || "")}">${escapeHtml(initial)}</span>
|
||||||
|
<span class="rl-resource-title"><strong>${escapeHtml(resource.title)}</strong><small>${escapeHtml(creators)}</small><small>${escapeHtml((resource.tags || []).slice(0, 3).join(" · "))}</small></span>
|
||||||
|
<span class="rl-badges"><span class="rl-badge">${escapeHtml(resource.type)}</span><span class="rl-badge">${escapeHtml(resource.status)}</span><span class="rl-badge">${escapeHtml(resource.format)}</span></span>
|
||||||
|
<span class="rl-progress"><span class="rl-progress__track"><span class="rl-progress__fill" style="width:${progress.percent}%"></span></span><small>${progress.percent}% complete</small></span>
|
||||||
|
<span class="rl-card-arrow" aria-hidden="true">›</span>
|
||||||
|
</article>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusGroupMarkup(group, resources) {
|
||||||
|
const headingId = `rl-status-${group.id}`;
|
||||||
|
const countLabel = `${resources.length} ${resources.length === 1 ? "resource" : "resources"}`;
|
||||||
|
return `<section class="rl-status-group rl-status-group--${group.id}" aria-labelledby="${headingId}">
|
||||||
|
<header class="rl-status-group__head">
|
||||||
|
<span class="rl-status-group__marker" aria-hidden="true"></span>
|
||||||
|
<span class="rl-status-group__title"><strong id="${headingId}">${escapeHtml(group.label)}</strong><small>${escapeHtml(group.description)}</small></span>
|
||||||
|
<span class="rl-status-group__count" aria-label="${countLabel}">${resources.length}</span>
|
||||||
|
</header>
|
||||||
|
<div class="rl-status-resources">${resources.map(cardMarkup).join("")}</div>
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hydrateThumbnails(container) {
|
||||||
|
const targets = Array.from(container.querySelectorAll("[data-thumb]")).filter((item) => item.dataset.thumb);
|
||||||
|
await Promise.all(targets.map(async (target) => {
|
||||||
|
const url = await thumbnailUrl(target.dataset.thumb);
|
||||||
|
if (url && target.isConnected) target.innerHTML = `<img src="${url}" alt="" />`;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindResourceCards() {
|
||||||
|
els.resourceList.querySelectorAll("[data-resource-id]").forEach((card) => {
|
||||||
|
const open = () => openResourceDialog(currentResource(card.dataset.resourceId));
|
||||||
|
card.addEventListener("click", open);
|
||||||
|
card.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") { event.preventDefault(); open(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResources() {
|
||||||
|
const resources = filteredResources();
|
||||||
|
const folder = currentVault().folders.find((item) => item.id === state.folderId);
|
||||||
|
els.contextLabel.textContent = state.folderId === "all" ? "All resources" : state.folderId === "unfiled" ? "Unfiled" : folder ? folder.name : "All resources";
|
||||||
|
els.resourceList.classList.toggle("is-grid", state.view === "grid");
|
||||||
|
$("#rl-view-list").classList.toggle("is-active", state.view === "list");
|
||||||
|
$("#rl-view-grid").classList.toggle("is-active", state.view === "grid");
|
||||||
|
$("#rl-view-list").setAttribute("aria-pressed", String(state.view === "list"));
|
||||||
|
$("#rl-view-grid").setAttribute("aria-pressed", String(state.view === "grid"));
|
||||||
|
|
||||||
|
if (!resources.length) {
|
||||||
|
const filtered = currentVault().resources.length > 0;
|
||||||
|
els.resourceList.innerHTML = `<div class="rl-empty"><span class="rl-empty__mark">⌑</span><h3>${filtered ? "Nothing matches this view" : "Your shelf is ready"}</h3><p>${filtered ? "Try changing the folder, filters, or search." : "Add a physical book, save a link, or import a PDF to begin."}</p><button type="button" class="rl-primary" data-empty-add>Add resource</button></div>`;
|
||||||
|
els.resourceList.querySelector("[data-empty-add]").addEventListener("click", () => openResourceDialog());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
els.resourceList.innerHTML = STATUS_GROUPS.map((group) => {
|
||||||
|
const matching = resources.filter((resource) => resource.status === group.id);
|
||||||
|
return matching.length ? statusGroupMarkup(group, matching) : "";
|
||||||
|
}).join("");
|
||||||
|
bindResourceCards();
|
||||||
|
hydrateThumbnails(els.resourceList);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStats() {
|
||||||
|
const resources = currentVault().resources;
|
||||||
|
$("#rl-stat-total").textContent = resources.length;
|
||||||
|
$("#rl-stat-active").textContent = resources.filter((item) => item.status === "active").length;
|
||||||
|
$("#rl-stat-backlog").textContent = resources.filter((item) => ["backlog", "shelf"].includes(item.status)).length;
|
||||||
|
$("#rl-stat-completed").textContent = resources.filter((item) => item.status === "completed").length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
renderVaultSelect();
|
||||||
|
renderFolders();
|
||||||
|
populateTagFilter();
|
||||||
|
renderStats();
|
||||||
|
renderResources();
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateFolderField(selected) {
|
||||||
|
const field = $("#rl-field-folder");
|
||||||
|
field.innerHTML = '<option value="">Unfiled</option>' + currentVault().folders.slice().sort((a, b) => a.name.localeCompare(b.name)).map((folder) => `<option value="${escapeHtml(folder.id)}">${escapeHtml(folder.name)}</option>`).join("");
|
||||||
|
field.value = selected || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCover() {
|
||||||
|
$("#rl-cover-preview").innerHTML = "<span>Cover</span>";
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateResourceFormForType() {
|
||||||
|
const type = $("#rl-field-type").value;
|
||||||
|
const isBook = type === "book";
|
||||||
|
els.resourceDialog.classList.toggle("is-compact", !isBook);
|
||||||
|
els.resourceDialog.querySelectorAll("[data-resource-types]").forEach((element) => {
|
||||||
|
element.hidden = !element.dataset.resourceTypes.split(/\s+/).includes(type);
|
||||||
|
});
|
||||||
|
$("#rl-creators-label").textContent = type === "video" ? "Presenter / creator" : "Author(s)";
|
||||||
|
$("#rl-publisher-label").textContent = type === "video" ? "Channel" : "Publisher";
|
||||||
|
if (!isBook) $("#rl-field-format").value = "web";
|
||||||
|
if (!isBook) $("#rl-field-pages").value = "";
|
||||||
|
if (type !== "video") $("#rl-field-duration").value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showCover(id) {
|
||||||
|
resetCover();
|
||||||
|
const url = await thumbnailUrl(id);
|
||||||
|
if (url) $("#rl-cover-preview").innerHTML = `<img src="${url}" alt="Imported PDF cover" />`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSessions(resource) {
|
||||||
|
const section = $("#rl-session-section");
|
||||||
|
if (!resource) { section.hidden = true; return; }
|
||||||
|
section.hidden = false;
|
||||||
|
const sessions = (resource.sessions || []).slice().sort((a, b) => String(b.date).localeCompare(String(a.date)));
|
||||||
|
$("#rl-session-list").innerHTML = sessions.length ? sessions.map((session) => `<div class="rl-session-row">
|
||||||
|
<time datetime="${escapeHtml(session.date)}">${escapeHtml(session.date)}</time>
|
||||||
|
<strong>${escapeHtml(session.start)}–${escapeHtml(session.end)} ${escapeHtml(session.unit)}</strong>
|
||||||
|
<span>${escapeHtml(session.note || "")}</span>
|
||||||
|
<button type="button" class="rl-danger" data-delete-session="${escapeHtml(session.id)}">Delete</button>
|
||||||
|
</div>`).join("") : '<p class="rl-help">No sessions logged yet.</p>';
|
||||||
|
$("#rl-session-list").querySelectorAll("[data-delete-session]").forEach((button) => button.addEventListener("click", async () => {
|
||||||
|
if (!confirm("Delete this session?")) return;
|
||||||
|
resource.sessions = resource.sessions.filter((item) => item.id !== button.dataset.deleteSession);
|
||||||
|
resource.updatedAt = Model.now();
|
||||||
|
if (!await saveLibrary()) return;
|
||||||
|
renderSessions(resource);
|
||||||
|
render();
|
||||||
|
toast("Session deleted.");
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openResourceDialog(resource, draft) {
|
||||||
|
els.resourceForm.reset();
|
||||||
|
resetCover();
|
||||||
|
const item = resource || draft || null;
|
||||||
|
state.pendingImport = draft ? { attachmentId: draft.attachmentId, thumbnailId: draft.thumbnailId } : null;
|
||||||
|
$("#rl-resource-dialog-title").textContent = resource ? "Edit resource" : draft ? "Review imported PDF" : "Add resource";
|
||||||
|
setValue("rl-resource-id", resource ? resource.id : "");
|
||||||
|
setValue("rl-attachment-id", item && item.attachmentId);
|
||||||
|
setValue("rl-thumbnail-id", item && item.thumbnailId);
|
||||||
|
setValue("rl-field-title", item && item.title);
|
||||||
|
setValue("rl-field-creators", item && (item.creators || []).join(", "));
|
||||||
|
setValue("rl-field-type", item ? item.type : "book");
|
||||||
|
setValue("rl-field-format", item ? item.format : "physical");
|
||||||
|
setValue("rl-field-status", item ? item.status : "backlog");
|
||||||
|
setValue("rl-field-pages", item && item.pageCount);
|
||||||
|
setValue("rl-field-duration", item && item.durationMinutes);
|
||||||
|
setValue("rl-field-url", item && item.url);
|
||||||
|
setValue("rl-field-publisher", item && item.publisher);
|
||||||
|
setValue("rl-field-published", item && item.published);
|
||||||
|
setValue("rl-field-tags", item && (item.tags || []).join(", "));
|
||||||
|
setValue("rl-field-description", item && item.description);
|
||||||
|
setValue("rl-field-notes", item && item.notes);
|
||||||
|
updateResourceFormForType();
|
||||||
|
populateFolderField(item && item.folderId ? item.folderId : (!["all", "unfiled"].includes(state.folderId) ? state.folderId : ""));
|
||||||
|
$("#rl-delete-resource").hidden = !resource;
|
||||||
|
$("#rl-open-resource").hidden = !(item && (item.url || item.attachmentId));
|
||||||
|
renderSessions(resource);
|
||||||
|
if (item && item.thumbnailId) showCover(item.thumbnailId);
|
||||||
|
els.resourceDialog.showModal();
|
||||||
|
$("#rl-field-title").focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formResource(existing) {
|
||||||
|
const stamp = Model.now();
|
||||||
|
return {
|
||||||
|
id: existing ? existing.id : Model.id(),
|
||||||
|
title: $("#rl-field-title").value.trim(),
|
||||||
|
creators: splitList($("#rl-field-creators").value),
|
||||||
|
type: $("#rl-field-type").value,
|
||||||
|
format: $("#rl-field-format").value,
|
||||||
|
status: $("#rl-field-status").value,
|
||||||
|
folderId: $("#rl-field-folder").value || null,
|
||||||
|
pageCount: Number($("#rl-field-pages").value) || null,
|
||||||
|
durationMinutes: Number($("#rl-field-duration").value) || null,
|
||||||
|
url: $("#rl-field-url").value.trim(),
|
||||||
|
publisher: $("#rl-field-publisher").value.trim(),
|
||||||
|
published: $("#rl-field-published").value.trim(),
|
||||||
|
tags: splitList($("#rl-field-tags").value),
|
||||||
|
description: $("#rl-field-description").value.trim(),
|
||||||
|
notes: $("#rl-field-notes").value.trim(),
|
||||||
|
attachmentId: $("#rl-attachment-id").value || null,
|
||||||
|
thumbnailId: $("#rl-thumbnail-id").value || null,
|
||||||
|
sessions: existing ? existing.sessions || [] : [],
|
||||||
|
createdAt: existing ? existing.createdAt : stamp,
|
||||||
|
updatedAt: stamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveResource(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const vault = currentVault();
|
||||||
|
const existing = currentResource($("#rl-resource-id").value);
|
||||||
|
const resource = formResource(existing);
|
||||||
|
if (!resource.title) return;
|
||||||
|
if (existing) vault.resources[vault.resources.findIndex((item) => item.id === existing.id)] = resource;
|
||||||
|
else vault.resources.push(resource);
|
||||||
|
vault.updatedAt = Model.now();
|
||||||
|
if (!await saveLibrary()) return;
|
||||||
|
state.pendingImport = null;
|
||||||
|
closeDialog(els.resourceDialog);
|
||||||
|
render();
|
||||||
|
toast(existing ? "Resource updated." : "Resource added.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeResource() {
|
||||||
|
const resource = currentResource($("#rl-resource-id").value);
|
||||||
|
if (!resource || !confirm(`Delete “${resource.title}”? This also removes its retained PDF.`)) return;
|
||||||
|
const attachmentIds = [resource.attachmentId, resource.thumbnailId];
|
||||||
|
currentVault().resources = currentVault().resources.filter((item) => item.id !== resource.id);
|
||||||
|
currentVault().updatedAt = Model.now();
|
||||||
|
if (!await saveLibrary()) return;
|
||||||
|
await Promise.all(attachmentIds.map(deleteAttachment));
|
||||||
|
closeDialog(els.resourceDialog);
|
||||||
|
render();
|
||||||
|
toast("Resource deleted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSessionDialog() {
|
||||||
|
const resource = currentResource($("#rl-resource-id").value);
|
||||||
|
if (!resource) return;
|
||||||
|
const labels = resource.type === "book" ? ["Start page", "End page", "pages"] : resource.type === "video" ? ["Start minute", "End minute", "minutes"] : ["Start percentage", "End percentage", "percent"];
|
||||||
|
$("#rl-session-start-label").textContent = labels[0];
|
||||||
|
$("#rl-session-end-label").textContent = labels[1];
|
||||||
|
$("#rl-session-form").dataset.unit = labels[2];
|
||||||
|
$("#rl-session-form").reset();
|
||||||
|
$("#rl-session-date").value = new Date().toISOString().slice(0, 10);
|
||||||
|
const progress = Model.progressFor(resource);
|
||||||
|
$("#rl-session-start").value = progress.current || 0;
|
||||||
|
els.sessionDialog.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSession(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const resource = currentResource($("#rl-resource-id").value);
|
||||||
|
if (!resource) return;
|
||||||
|
const start = Number($("#rl-session-start").value);
|
||||||
|
const end = Number($("#rl-session-end").value);
|
||||||
|
if (end < start) { toast("The end position must be at or after the start.", true); return; }
|
||||||
|
if (resource.type === "article" && end > 100) { toast("Article progress cannot exceed 100%.", true); return; }
|
||||||
|
resource.sessions.push({ id: Model.id(), date: $("#rl-session-date").value, start, end, unit: event.currentTarget.dataset.unit, note: $("#rl-session-note").value.trim(), createdAt: Model.now() });
|
||||||
|
resource.updatedAt = Model.now();
|
||||||
|
if (Model.progressFor(resource).percent >= 100) resource.status = "completed";
|
||||||
|
else if (["backlog", "shelf"].includes(resource.status)) resource.status = "active";
|
||||||
|
if (!await saveLibrary()) return;
|
||||||
|
closeDialog(els.sessionDialog);
|
||||||
|
renderSessions(resource);
|
||||||
|
render();
|
||||||
|
toast("Session logged.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openLinkedResource() {
|
||||||
|
const resource = currentResource($("#rl-resource-id").value);
|
||||||
|
if (!resource) return;
|
||||||
|
if (resource.attachmentId) {
|
||||||
|
const record = await getAttachment(resource.attachmentId);
|
||||||
|
if (!record || !record.blob) { toast("This PDF is not attached in this browser. Import it again to restore the file.", true); return; }
|
||||||
|
if (state.pdfUrls.has(resource.attachmentId)) URL.revokeObjectURL(state.pdfUrls.get(resource.attachmentId));
|
||||||
|
const url = URL.createObjectURL(record.blob);
|
||||||
|
state.pdfUrls.set(resource.attachmentId, url);
|
||||||
|
window.open(url, "_blank", "noopener");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (resource.url) window.open(resource.url, "_blank", "noopener");
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNameDialog(action, title, value, colour) {
|
||||||
|
state.nameAction = action;
|
||||||
|
$("#rl-name-title").textContent = title;
|
||||||
|
$("#rl-name-input").value = value || "";
|
||||||
|
$("#rl-colour-field").hidden = !String(action).includes("vault");
|
||||||
|
$("#rl-name-colour").value = colour || "#9b6b43";
|
||||||
|
$("#rl-name-delete").hidden = !String(action).startsWith("rename-folder:");
|
||||||
|
els.nameDialog.showModal();
|
||||||
|
$("#rl-name-input").focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitName(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const name = $("#rl-name-input").value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
const action = state.nameAction || "";
|
||||||
|
if (action === "add-folder") state.library = Model.addFolder(state.library, state.vaultId, name);
|
||||||
|
if (action.startsWith("rename-folder:")) {
|
||||||
|
const folder = currentVault().folders.find((item) => item.id === action.split(":")[1]);
|
||||||
|
folder.name = name; folder.updatedAt = Model.now(); currentVault().updatedAt = Model.now();
|
||||||
|
}
|
||||||
|
if (action === "add-vault") {
|
||||||
|
state.library = Model.addVault(state.library, name, $("#rl-name-colour").value);
|
||||||
|
state.vaultId = state.library.vaults[state.library.vaults.length - 1].id;
|
||||||
|
state.folderId = "all";
|
||||||
|
}
|
||||||
|
if (action.startsWith("rename-vault:")) {
|
||||||
|
state.library = Model.renameVault(state.library, action.split(":")[1], name);
|
||||||
|
const vault = state.library.vaults.find((item) => item.id === action.split(":")[1]);
|
||||||
|
vault.colour = $("#rl-name-colour").value;
|
||||||
|
}
|
||||||
|
if (!await saveLibrary()) return;
|
||||||
|
closeDialog(els.nameDialog);
|
||||||
|
render();
|
||||||
|
renderVaultManager();
|
||||||
|
toast("Saved.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function manageFolder(folderId) {
|
||||||
|
const folder = currentVault().folders.find((item) => item.id === folderId);
|
||||||
|
if (!folder) return;
|
||||||
|
openNameDialog(`rename-folder:${folderId}`, "Manage folder", folder.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteNamedFolder() {
|
||||||
|
const action = state.nameAction || "";
|
||||||
|
if (!action.startsWith("rename-folder:")) return;
|
||||||
|
const folderId = action.split(":")[1];
|
||||||
|
const folder = currentVault().folders.find((item) => item.id === folderId);
|
||||||
|
if (!folder || !confirm(`Delete “${folder.name}”? Its resources will move to Unfiled.`)) return;
|
||||||
|
state.library = Model.deleteFolder(state.library, state.vaultId, folderId);
|
||||||
|
if (!await saveLibrary()) return;
|
||||||
|
closeDialog(els.nameDialog);
|
||||||
|
render();
|
||||||
|
toast("Folder deleted; resources moved to Unfiled.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupPendingImport() {
|
||||||
|
const pending = state.pendingImport;
|
||||||
|
if (!pending) return;
|
||||||
|
state.pendingImport = null;
|
||||||
|
await Promise.all([deleteAttachment(pending.attachmentId), deleteAttachment(pending.thumbnailId)]).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVaultManager() {
|
||||||
|
$("#rl-vault-list").innerHTML = state.library.vaults.map((vault) => `<div class="rl-vault-row">
|
||||||
|
<span class="rl-vault-dot" style="--vault-colour:${escapeHtml(vault.colour)}"></span><strong>${escapeHtml(vault.name)}</strong>
|
||||||
|
<button type="button" data-rename-vault="${escapeHtml(vault.id)}">Rename</button>
|
||||||
|
<button type="button" class="rl-danger" data-delete-vault="${escapeHtml(vault.id)}" ${state.library.vaults.length === 1 ? "disabled" : ""}>Delete</button>
|
||||||
|
</div>`).join("");
|
||||||
|
$("#rl-vault-list").querySelectorAll("[data-rename-vault]").forEach((button) => button.addEventListener("click", () => {
|
||||||
|
const vault = state.library.vaults.find((item) => item.id === button.dataset.renameVault);
|
||||||
|
closeDialog(els.vaultDialog);
|
||||||
|
openNameDialog(`rename-vault:${vault.id}`, "Rename vault", vault.name, vault.colour);
|
||||||
|
}));
|
||||||
|
$("#rl-vault-list").querySelectorAll("[data-delete-vault]").forEach((button) => button.addEventListener("click", async () => {
|
||||||
|
const vault = state.library.vaults.find((item) => item.id === button.dataset.deleteVault);
|
||||||
|
if (!confirm(`Delete the “${vault.name}” vault and all of its records? Retained PDFs will also be removed.`)) return;
|
||||||
|
const attachmentIds = vault.resources.flatMap((resource) => [resource.attachmentId, resource.thumbnailId]);
|
||||||
|
state.library = Model.deleteVault(state.library, vault.id);
|
||||||
|
state.vaultId = state.library.vaults[0].id;
|
||||||
|
state.folderId = "all";
|
||||||
|
if (!await saveLibrary()) return;
|
||||||
|
await Promise.all(attachmentIds.map(deleteAttachment));
|
||||||
|
renderVaultManager(); render(); toast("Vault deleted.");
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportJson() {
|
||||||
|
const blob = new Blob([Model.serialise(state.library)], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = `resource-loader-${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
|
link.click();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
toast("JSON backup exported. PDF files are not included.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonFile(file) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(await file.text());
|
||||||
|
Model.validateLibrary(data);
|
||||||
|
state.importData = Model.normaliseLibrary(data);
|
||||||
|
const vaults = state.importData.vaults.length;
|
||||||
|
const folders = state.importData.vaults.reduce((sum, vault) => sum + vault.folders.length, 0);
|
||||||
|
const resources = state.importData.vaults.reduce((sum, vault) => sum + vault.resources.length, 0);
|
||||||
|
$("#rl-import-summary").innerHTML = `<div><strong>${vaults}</strong><span>vaults</span></div><div><strong>${folders}</strong><span>folders</span></div><div><strong>${resources}</strong><span>resources</span></div>`;
|
||||||
|
els.importDialog.showModal();
|
||||||
|
} catch (error) {
|
||||||
|
toast(`Import rejected: ${error.message}`, true);
|
||||||
|
} finally {
|
||||||
|
els.jsonInput.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyImport(mode) {
|
||||||
|
if (!state.importData) return;
|
||||||
|
if (mode === "replace" && !confirm("Replace every vault and resource? Retained PDF files will also be cleared.")) return;
|
||||||
|
state.library = mode === "merge" ? Model.mergeLibraries(state.library, state.importData) : state.importData;
|
||||||
|
state.vaultId = state.library.vaults[0] && state.library.vaults[0].id;
|
||||||
|
state.folderId = "all";
|
||||||
|
if (!await saveLibrary()) return;
|
||||||
|
if (mode === "replace") await request(ATTACHMENT_STORE, "readwrite", (store) => store.clear());
|
||||||
|
state.importData = null;
|
||||||
|
closeDialog(els.importDialog);
|
||||||
|
render();
|
||||||
|
toast(mode === "merge" ? "Library merged." : "Library replaced.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanPdfTitle(filename) {
|
||||||
|
return filename.replace(/\.pdf$/i, "").replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim().replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pdfCover(page) {
|
||||||
|
const viewport = page.getViewport({ scale: 0.7 });
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const maximum = 420;
|
||||||
|
const scale = Math.min(1, maximum / viewport.width);
|
||||||
|
const scaled = page.getViewport({ scale: 0.7 * scale });
|
||||||
|
canvas.width = Math.ceil(scaled.width);
|
||||||
|
canvas.height = Math.ceil(scaled.height);
|
||||||
|
await page.render({ canvasContext: canvas.getContext("2d"), viewport: scaled }).promise;
|
||||||
|
return new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", .78));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPdfJs() {
|
||||||
|
if (globalThis.pdfjsLib) return Promise.resolve(globalThis.pdfjsLib);
|
||||||
|
if (pdfJsPromise) return pdfJsPromise;
|
||||||
|
pdfJsPromise = new Promise((resolve, reject) => {
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = "/assets/scripts/vendor/pdf.min.js";
|
||||||
|
script.async = true;
|
||||||
|
script.onload = () => globalThis.pdfjsLib ? resolve(globalThis.pdfjsLib) : reject(new Error("PDF.js loaded without exposing its API."));
|
||||||
|
script.onerror = () => reject(new Error("The local PDF.js library could not be loaded."));
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
return pdfJsPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importPdf(file) {
|
||||||
|
if (!file || (file.type && file.type !== "application/pdf") || !file.name.toLowerCase().endsWith(".pdf")) {
|
||||||
|
toast("Choose a PDF file.", true); return;
|
||||||
|
}
|
||||||
|
showLoading("Reading PDF metadata…");
|
||||||
|
let attachmentId;
|
||||||
|
let thumbnailId;
|
||||||
|
try {
|
||||||
|
const pdfjs = await loadPdfJs();
|
||||||
|
pdfjs.GlobalWorkerOptions.workerSrc = "/assets/scripts/vendor/pdf.worker.min.js";
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
const documentTask = pdfjs.getDocument({ data: bytes });
|
||||||
|
const pdf = await documentTask.promise;
|
||||||
|
const metadata = await pdf.getMetadata().catch(() => ({ info: {}, metadata: null }));
|
||||||
|
const info = metadata.info || {};
|
||||||
|
attachmentId = Model.id();
|
||||||
|
await putAttachment({ id: attachmentId, kind: "pdf", name: file.name, type: file.type || "application/pdf", size: file.size, blob: file, createdAt: Model.now() });
|
||||||
|
const cover = await pdfCover(await pdf.getPage(1)).catch(() => null);
|
||||||
|
if (cover) {
|
||||||
|
thumbnailId = Model.id();
|
||||||
|
await putAttachment({ id: thumbnailId, kind: "thumbnail", name: `${file.name}.jpg`, type: "image/jpeg", size: cover.size, blob: cover, createdAt: Model.now() });
|
||||||
|
}
|
||||||
|
const keywords = Array.isArray(info.Keywords) ? info.Keywords : splitList(info.Keywords || "");
|
||||||
|
const draft = {
|
||||||
|
title: String(info.Title || "").trim() || cleanPdfTitle(file.name),
|
||||||
|
creators: splitList(info.Author || ""),
|
||||||
|
type: "book", format: "pdf", status: "shelf", pageCount: pdf.numPages,
|
||||||
|
description: String(info.Subject || "").trim(), tags: keywords,
|
||||||
|
publisher: String(info.Producer || info.Creator || "").trim(),
|
||||||
|
published: String(info.CreationDate || "").replace(/^D:/, "").slice(0, 8),
|
||||||
|
notes: "", attachmentId, thumbnailId: thumbnailId || null,
|
||||||
|
};
|
||||||
|
await pdf.destroy();
|
||||||
|
hideLoading();
|
||||||
|
await openResourceDialog(null, draft);
|
||||||
|
toast("PDF metadata loaded. Review it before saving.");
|
||||||
|
} catch (error) {
|
||||||
|
hideLoading();
|
||||||
|
if (attachmentId) await deleteAttachment(attachmentId).catch(() => {});
|
||||||
|
if (thumbnailId) await deleteAttachment(thumbnailId).catch(() => {});
|
||||||
|
const reason = error && error.name === "PasswordException" ? "The PDF is password protected." : error && error.name === "QuotaExceededError" ? "Browser storage is full." : "The PDF could not be read.";
|
||||||
|
const detail = error && error.message ? ` ${error.message}` : "";
|
||||||
|
toast(`${reason}${detail} No resource was created.`, true);
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
els.pdfInput.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showStorage() {
|
||||||
|
els.storageDialog.showModal();
|
||||||
|
if (navigator.storage && navigator.storage.estimate) {
|
||||||
|
const estimate = await navigator.storage.estimate();
|
||||||
|
const used = estimate.usage || 0;
|
||||||
|
const quota = estimate.quota || 0;
|
||||||
|
const format = (bytes) => bytes < 1024 * 1024 ? `${Math.round(bytes / 1024)} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
$("#rl-storage-summary").textContent = `${format(used)} used of approximately ${format(quota)} available to this site.`;
|
||||||
|
} else $("#rl-storage-summary").textContent = "This browser does not report storage estimates.";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestPersistence() {
|
||||||
|
if (!navigator.storage || !navigator.storage.persist) { toast("Persistent storage is not supported by this browser.", true); return; }
|
||||||
|
const granted = await navigator.storage.persist();
|
||||||
|
toast(granted ? "Persistent storage granted." : "The browser did not grant persistent storage.", !granted);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearData() {
|
||||||
|
if (!confirm("Remove retained PDFs from this browser? Shared server metadata and thumbnails will remain available.")) return;
|
||||||
|
closeDialog(els.storageDialog);
|
||||||
|
state.db.close();
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const deletion = indexedDB.deleteDatabase(DB_NAME);
|
||||||
|
deletion.onsuccess = resolve; deletion.onerror = () => reject(deletion.error); deletion.onblocked = resolve;
|
||||||
|
});
|
||||||
|
localStorage.removeItem("rl-view");
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
state.eventsBound = true;
|
||||||
|
els.vaultSelect.addEventListener("change", () => { state.vaultId = els.vaultSelect.value; state.folderId = "all"; render(); });
|
||||||
|
[els.search, els.typeFilter, els.statusFilter, els.tagFilter, els.sort].forEach((control) => control.addEventListener(control === els.search ? "input" : "change", renderResources));
|
||||||
|
$("#rl-view-list").addEventListener("click", () => { state.view = "list"; localStorage.setItem("rl-view", state.view); renderResources(); });
|
||||||
|
$("#rl-view-grid").addEventListener("click", () => { state.view = "grid"; localStorage.setItem("rl-view", state.view); renderResources(); });
|
||||||
|
$("#rl-add-resource").addEventListener("click", () => openResourceDialog());
|
||||||
|
$("#rl-add-folder").addEventListener("click", () => openNameDialog("add-folder", "Add folder"));
|
||||||
|
$("#rl-manage-vaults").addEventListener("click", () => { renderVaultManager(); els.vaultDialog.showModal(); });
|
||||||
|
$("#rl-add-vault").addEventListener("click", () => { closeDialog(els.vaultDialog); openNameDialog("add-vault", "Add vault", "", "#9b6b43"); });
|
||||||
|
els.resourceForm.addEventListener("submit", saveResource);
|
||||||
|
$("#rl-delete-resource").addEventListener("click", removeResource);
|
||||||
|
$("#rl-open-resource").addEventListener("click", openLinkedResource);
|
||||||
|
$("#rl-add-session").addEventListener("click", openSessionDialog);
|
||||||
|
$("#rl-field-type").addEventListener("change", updateResourceFormForType);
|
||||||
|
$("#rl-session-form").addEventListener("submit", saveSession);
|
||||||
|
$("#rl-name-form").addEventListener("submit", submitName);
|
||||||
|
$("#rl-name-delete").addEventListener("click", deleteNamedFolder);
|
||||||
|
$("#rl-import-pdf").addEventListener("click", () => els.pdfInput.click());
|
||||||
|
els.pdfInput.addEventListener("change", () => importPdf(els.pdfInput.files[0]));
|
||||||
|
$("#rl-import-json").addEventListener("click", () => els.jsonInput.click());
|
||||||
|
els.jsonInput.addEventListener("change", () => readJsonFile(els.jsonInput.files[0]));
|
||||||
|
$("#rl-export-json").addEventListener("click", exportJson);
|
||||||
|
$("#rl-import-merge").addEventListener("click", () => applyImport("merge"));
|
||||||
|
$("#rl-import-replace").addEventListener("click", () => applyImport("replace"));
|
||||||
|
$("#rl-storage-button").addEventListener("click", showStorage);
|
||||||
|
$("#rl-request-persistence").addEventListener("click", requestPersistence);
|
||||||
|
$("#rl-clear-data").addEventListener("click", clearData);
|
||||||
|
$$('[data-close-dialog]').forEach((button) => button.addEventListener("click", () => closeDialog(button.closest("dialog"))));
|
||||||
|
$$('dialog').forEach((dialog) => dialog.addEventListener("click", (event) => { if (event.target === dialog) closeDialog(dialog); }));
|
||||||
|
els.resourceDialog.addEventListener("close", cleanupPendingImport);
|
||||||
|
window.addEventListener("beforeunload", () => {
|
||||||
|
state.pdfUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||||
|
state.thumbnailUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initialise() {
|
||||||
|
showLoading("Opening your library…");
|
||||||
|
try {
|
||||||
|
state.db = await openDatabase();
|
||||||
|
state.library = await loadLibrary();
|
||||||
|
state.vaultId = state.library.vaults[0].id;
|
||||||
|
bindEvents();
|
||||||
|
render();
|
||||||
|
uploadLocalThumbnails(state.library).catch((error) => {
|
||||||
|
console.warn("Some generated covers could not be uploaded yet.", error);
|
||||||
|
toast("The library is synced, but a generated cover could not be uploaded.", true);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setSyncStatus("error", "server unavailable");
|
||||||
|
root.innerHTML = `<div class="rl-empty"><span class="rl-empty__mark">!</span><h3>Server library is unavailable</h3><p>${escapeHtml(error.message || "The shared Resource Loader library could not be loaded.")}</p></div>`;
|
||||||
|
} finally {
|
||||||
|
hideLoading();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initialise();
|
||||||
|
})();
|
||||||
0
assets/scripts/pages/sitemap-interactive.js
Normal file → Executable file
2
assets/scripts/pages/wird-tracker.js
Normal file → Executable file
@@ -5,6 +5,8 @@
|
|||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
|
if (!document.getElementById("wird-app")) return;
|
||||||
|
|
||||||
const API = "/api/wird";
|
const API = "/api/wird";
|
||||||
|
|
||||||
// type: "count" | "juz" | "min" | "rating" | "nafl" | "khatm"
|
// type: "count" | "juz" | "min" | "rating" | "nafl" | "khatm"
|
||||||
|
|||||||
0
assets/scripts/play.js
Normal file → Executable file
0
assets/scripts/ui/copy-buttons.js
Normal file → Executable file
0
assets/scripts/ui/countdown.js
Normal file → Executable file
0
assets/scripts/ui/footnote-sidenotes.js
Normal file → Executable file
0
assets/scripts/ui/gallery-init.js
Normal file → Executable file
0
assets/scripts/ui/mermaid-diagrams.js
Normal file → Executable file
6
assets/scripts/ui/toc-active-link.js
Normal file → Executable file
@@ -1,11 +1,11 @@
|
|||||||
/* Event listener for scrolling and changing the active label on the TOC */
|
/* Event listener for scrolling and changing the active label on the TOC */
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const toc = document.querySelector("#text-table-of-contents");
|
const toc = document.querySelector("#text-table-of-contents");
|
||||||
if (!toc) { console.warn("No #text-table-of-contents found"); return; }
|
if (!toc) return;
|
||||||
|
|
||||||
const links = toc.querySelectorAll('a[href^="#"]'); // '^=' is a starts with operator.
|
const links = toc.querySelectorAll('a[href^="#"]'); // '^=' is a starts with operator.
|
||||||
// <a href="#introduction">Intro</a> matches
|
// <a href="#introduction">Intro</a> matches
|
||||||
if (!links.length) { console.warn("No ToC links found"); return; }
|
if (!links.length) return;
|
||||||
|
|
||||||
// Map: id -> link
|
// Map: id -> link
|
||||||
const linkById = new Map();
|
const linkById = new Map();
|
||||||
@@ -14,7 +14,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el) linkById.set(id, a);
|
if (el) linkById.set(id, a);
|
||||||
});
|
});
|
||||||
if (!linkById.size) { console.warn("No matching headings with IDs"); return; }
|
if (!linkById.size) return;
|
||||||
|
|
||||||
// Headings to observe (h2–h4 usually)
|
// Headings to observe (h2–h4 usually)
|
||||||
const headings = [...document.querySelectorAll("h2[id], h3[id], h4[id]")]
|
const headings = [...document.querySelectorAll("h2[id], h3[id], h4[id]")]
|
||||||
|
|||||||
0
assets/scripts/vendor/bigger-picture.min.js
vendored
Normal file → Executable file
0
assets/scripts/vendor/lunr.js
vendored
Normal file → Executable file
0
assets/scripts/vendor/mermaid.min.js
vendored
Normal file → Executable file
22
assets/scripts/vendor/pdf.min.js
vendored
Executable file
22
assets/scripts/vendor/pdf.worker.min.js
vendored
Executable file
177
assets/scripts/vendor/pdfjs-LICENSE.txt
vendored
Executable file
@@ -0,0 +1,177 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
0
assets/scripts/vendor/svg-pan-zoom.min.js
vendored
Normal file → Executable file
0
assets/styles/base/elements.css
Normal file → Executable file
0
assets/styles/components/comments.css
Normal file → Executable file
0
assets/styles/components/kanban.css
Normal file → Executable file
0
assets/styles/components/mermaid.css
Normal file → Executable file
0
assets/styles/components/notes-board.css
Normal file → Executable file
5
assets/styles/components/org-content.css
Normal file → Executable file
@@ -246,9 +246,10 @@ time.countdown.expired {
|
|||||||
#search-btn {
|
#search-btn {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
flex: 0 0 2.25rem;
|
flex: 0 0 auto;
|
||||||
width: 2.25rem;
|
min-width: 2.25rem;
|
||||||
height: 2.25rem;
|
height: 2.25rem;
|
||||||
|
padding: 0 0.7rem;
|
||||||
background: color-mix(in oklab, var(--surface-soft) 82%, transparent);
|
background: color-mix(in oklab, var(--surface-soft) 82%, transparent);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
|
|||||||
0
assets/styles/components/org-syntax.css
Normal file → Executable file
0
assets/styles/features/hidden-details.css
Normal file → Executable file
0
assets/styles/hidden-details.css
Normal file → Executable file
0
assets/styles/layout/site-shell.css
Normal file → Executable file
@@ -1 +1 @@
|
|||||||
@import url("./components/org-content.css");
|
@import url("./components/org-content.css?v=20260819-search-button");
|
||||||
|
|||||||
3
assets/styles/pages/home-dashboard.css
Normal file → Executable file
@@ -194,7 +194,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.db-qn-item--writing,
|
.db-qn-item--writing,
|
||||||
.db-qn-item--notes { border-left-color: var(--db-blue); }
|
.db-qn-item--notes,
|
||||||
|
.db-qn-item--reading { border-left-color: var(--db-blue); }
|
||||||
.db-qn-item--career,
|
.db-qn-item--career,
|
||||||
.db-qn-item--wird { border-left-color: var(--db-green); }
|
.db-qn-item--wird { border-left-color: var(--db-green); }
|
||||||
.db-qn-item--lima,
|
.db-qn-item--lima,
|
||||||
|
|||||||
390
assets/styles/pages/play.css
Normal file → Executable file
@@ -307,16 +307,14 @@
|
|||||||
transform: rotate(-4deg);
|
transform: rotate(-4deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bookshelf,
|
.bookshelf {
|
||||||
.timeline-list {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.book-spine,
|
.book-spine {
|
||||||
.timeline-card {
|
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
@@ -350,54 +348,6 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-list {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-card {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 5rem 1fr;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-card span {
|
|
||||||
color: var(--play-brass);
|
|
||||||
font-weight: 900;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-tool {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(180px, 280px) minmax(0, 1fr);
|
|
||||||
gap: 1rem;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-wheel {
|
|
||||||
aspect-ratio: 1;
|
|
||||||
width: 100%;
|
|
||||||
border-radius: 50% !important;
|
|
||||||
background:
|
|
||||||
conic-gradient(from 0deg, var(--play-red), var(--play-brass), var(--play-green), var(--play-blue), var(--play-red));
|
|
||||||
color: #fff8e8 !important;
|
|
||||||
font-weight: 900;
|
|
||||||
transition: transform 900ms cubic-bezier(.18, .88, .2, 1.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-result {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.7rem;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-result li {
|
|
||||||
padding: 0.85rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 7px;
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-tool {
|
.terminal-tool {
|
||||||
background: #15120e;
|
background: #15120e;
|
||||||
color: #f2e3bd;
|
color: #f2e3bd;
|
||||||
@@ -494,339 +444,3 @@
|
|||||||
.sigil-canvas {
|
.sigil-canvas {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rpg-shell {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 960px) minmax(280px, 1fr);
|
|
||||||
gap: 1rem;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-rpg-root {
|
|
||||||
--ash-black: #05070c;
|
|
||||||
--ash-ink: #0d1420;
|
|
||||||
--ash-panel: #121a27;
|
|
||||||
--ash-line: #344154;
|
|
||||||
--ash-gold: #e0a451;
|
|
||||||
--ash-cold: #8bd3ff;
|
|
||||||
--ash-red: #db4f62;
|
|
||||||
--ash-text: #f8efe0;
|
|
||||||
--ash-muted: #a8b4c2;
|
|
||||||
max-width: 1380px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-rpg-root .play-page-head {
|
|
||||||
padding: 1rem 0;
|
|
||||||
border-bottom: 1px solid rgba(139, 211, 255, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-rpg-root .play-page-head h1 {
|
|
||||||
color: var(--ash-text);
|
|
||||||
font-family: Georgia, "Times New Roman", serif;
|
|
||||||
text-shadow: 0 0 18px rgba(139, 211, 255, 0.22);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-rpg-root .play-page-head p,
|
|
||||||
.ash-rpg-root .play-back {
|
|
||||||
color: var(--ash-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-rpg-shell {
|
|
||||||
position: relative;
|
|
||||||
border-color: rgba(139, 211, 255, 0.22);
|
|
||||||
background:
|
|
||||||
linear-gradient(180deg, rgba(12, 20, 32, 0.96), rgba(4, 7, 12, 0.98)),
|
|
||||||
var(--ash-black);
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 1px rgba(224, 164, 81, 0.14) inset,
|
|
||||||
0 28px 80px rgba(0, 0, 0, 0.42);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-rpg-shell::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
background:
|
|
||||||
repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.025), rgba(255, 255, 255, 0.025) 1px, transparent 1px, transparent 4px);
|
|
||||||
mix-blend-mode: screen;
|
|
||||||
opacity: 0.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-stage {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-canvas {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 16 / 9;
|
|
||||||
image-rendering: pixelated;
|
|
||||||
border: 2px solid var(--ash-line);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--ash-black);
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 4px #05070c,
|
|
||||||
0 0 34px rgba(139, 211, 255, 0.16);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-rpg-canvas {
|
|
||||||
min-height: 360px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-dialogue,
|
|
||||||
.rpg-panel {
|
|
||||||
border: 1px solid var(--ash-line);
|
|
||||||
border-radius: 8px;
|
|
||||||
background:
|
|
||||||
linear-gradient(180deg, rgba(21, 31, 46, 0.95), rgba(8, 12, 18, 0.96)),
|
|
||||||
var(--ash-panel);
|
|
||||||
color: var(--ash-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-dialogue {
|
|
||||||
min-height: 130px;
|
|
||||||
padding: 1rem;
|
|
||||||
box-shadow: 0 0 0 1px rgba(248, 239, 224, 0.05) inset;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-dialogue strong,
|
|
||||||
.rpg-list h2 {
|
|
||||||
color: var(--ash-gold);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-dialogue p,
|
|
||||||
.rpg-status {
|
|
||||||
margin: 0.35rem 0 0;
|
|
||||||
color: var(--ash-muted);
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-panel {
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 1rem;
|
|
||||||
align-content: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-actions,
|
|
||||||
.rpg-mobile-pad {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-rpg-root button {
|
|
||||||
border-color: rgba(139, 211, 255, 0.28);
|
|
||||||
background: linear-gradient(180deg, #1a2638, #0c121c);
|
|
||||||
color: var(--ash-text);
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
|
||||||
font-weight: 800;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-rpg-root button:hover:not(:disabled),
|
|
||||||
.ash-rpg-root button:focus-visible {
|
|
||||||
background: linear-gradient(180deg, #31435f, #142236);
|
|
||||||
color: #ffffff;
|
|
||||||
box-shadow: 0 0 18px rgba(139, 211, 255, 0.22);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-rpg-root button:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.42;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-mobile-pad {
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-mobile-pad [data-rpg-move="up"],
|
|
||||||
.rpg-mobile-pad [data-rpg-move="down"] {
|
|
||||||
grid-column: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-mobile-pad [data-rpg-move="left"] {
|
|
||||||
grid-column: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-mobile-pad [data-rpg-act] {
|
|
||||||
grid-column: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-mobile-pad [data-rpg-move="right"] {
|
|
||||||
grid-column: 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-stats {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.55rem;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-stats div {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
padding-bottom: 0.45rem;
|
|
||||||
border-bottom: 1px solid rgba(139, 211, 255, 0.16);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-stats dt,
|
|
||||||
.rpg-list h2 {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--ash-muted);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 800;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-stats dd {
|
|
||||||
margin: 0;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-list ul {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.45rem;
|
|
||||||
margin: 0.45rem 0 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-list li {
|
|
||||||
padding: 0.55rem;
|
|
||||||
border: 1px solid rgba(139, 211, 255, 0.16);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: rgba(5, 7, 12, 0.62);
|
|
||||||
color: var(--ash-text);
|
|
||||||
font-size: 0.92rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-list li.is-done {
|
|
||||||
border-color: rgba(102, 199, 162, 0.42);
|
|
||||||
background: rgba(45, 91, 77, 0.34);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rpg-list li.is-erased {
|
|
||||||
border-color: rgba(219, 79, 98, 0.32);
|
|
||||||
color: rgba(248, 239, 224, 0.42);
|
|
||||||
background: repeating-linear-gradient(90deg, rgba(219, 79, 98, 0.08), rgba(219, 79, 98, 0.08) 6px, rgba(5, 7, 12, 0.6) 6px, rgba(5, 7, 12, 0.6) 12px);
|
|
||||||
text-decoration: line-through;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-choicebar {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-choicebar button {
|
|
||||||
min-height: 50px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-memory-meter {
|
|
||||||
position: relative;
|
|
||||||
display: grid;
|
|
||||||
gap: 0.45rem;
|
|
||||||
color: var(--ash-muted);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 800;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-memory-meter::after {
|
|
||||||
content: "";
|
|
||||||
display: block;
|
|
||||||
height: 10px;
|
|
||||||
border: 1px solid rgba(139, 211, 255, 0.24);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #05070c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-memory-meter i {
|
|
||||||
position: absolute;
|
|
||||||
left: 1px;
|
|
||||||
right: auto;
|
|
||||||
bottom: 1px;
|
|
||||||
width: 0;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: linear-gradient(90deg, var(--ash-cold), var(--ash-red));
|
|
||||||
transition: width 220ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-name-form {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-name-form label {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.35rem;
|
|
||||||
color: var(--ash-muted);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 800;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-name-form input {
|
|
||||||
width: 100%;
|
|
||||||
border: 1px solid rgba(139, 211, 255, 0.24);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.65rem;
|
|
||||||
background: #05070c;
|
|
||||||
color: var(--ash-text);
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-rpg-root.is-lake-awake .rpg-dialogue,
|
|
||||||
.ash-rpg-root.is-lake-awake .rpg-panel {
|
|
||||||
border-color: rgba(219, 79, 98, 0.36);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-rpg-root.is-lake-awake .ash-rpg-canvas {
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 4px #05070c,
|
|
||||||
0 0 38px rgba(219, 79, 98, 0.22);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-rpg-root.is-ending .rpg-panel {
|
|
||||||
box-shadow: 0 0 34px rgba(224, 164, 81, 0.16);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
|
||||||
.play-hero,
|
|
||||||
.play-console,
|
|
||||||
.recipe-tool,
|
|
||||||
.sigil-tool,
|
|
||||||
.rpg-shell {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-orbit {
|
|
||||||
min-height: 260px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-board {
|
|
||||||
grid-template-columns: repeat(3, minmax(64px, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.book-spine {
|
|
||||||
width: calc(50% - 0.4rem);
|
|
||||||
min-height: 160px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-choicebar,
|
|
||||||
.ash-name-form {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ash-rpg-canvas {
|
|
||||||
min-height: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
423
assets/styles/pages/resource-loader.css
Executable file
@@ -0,0 +1,423 @@
|
|||||||
|
.rl-app,
|
||||||
|
.rl-dialog {
|
||||||
|
--rl-ink: var(--fg);
|
||||||
|
--rl-paper: color-mix(in oklab, var(--surface) 94%, #efe5d4 6%);
|
||||||
|
--rl-paper-deep: color-mix(in oklab, var(--surface-soft) 82%, #d9bea0 18%);
|
||||||
|
--rl-line: color-mix(in oklab, var(--border) 80%, #9b6b43 20%);
|
||||||
|
--rl-accent: color-mix(in oklab, var(--accent) 54%, #9b6b43 46%);
|
||||||
|
--rl-accent-strong: color-mix(in oklab, var(--accent) 36%, #744729 64%);
|
||||||
|
--rl-muted: var(--muted);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-app [hidden],
|
||||||
|
.rl-dialog [hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-app {
|
||||||
|
min-height: 72vh;
|
||||||
|
color: var(--rl-ink);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 92% 4%, color-mix(in oklab, var(--rl-accent) 12%, transparent) 0, transparent 28rem),
|
||||||
|
linear-gradient(180deg, color-mix(in oklab, var(--rl-paper) 86%, transparent), transparent 36rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 2rem;
|
||||||
|
padding: 2.25rem 2rem 1.7rem;
|
||||||
|
border: 1px solid var(--rl-line);
|
||||||
|
border-radius: 18px 18px 0 0;
|
||||||
|
background: var(--rl-paper);
|
||||||
|
box-shadow: 0 18px 50px color-mix(in oklab, #000 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-hero h1,
|
||||||
|
.rl-library h2,
|
||||||
|
.rl-sidebar h2,
|
||||||
|
.rl-dialog h2,
|
||||||
|
.rl-dialog h3 {
|
||||||
|
font-family: Georgia, "Times New Roman", serif;
|
||||||
|
color: var(--heading);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-hero h1 {
|
||||||
|
font-size: clamp(2.1rem, 5vw, 4.25rem);
|
||||||
|
line-height: .95;
|
||||||
|
letter-spacing: -.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-hero__copy > p:last-child {
|
||||||
|
max-width: 42rem;
|
||||||
|
margin: .8rem 0 0;
|
||||||
|
color: var(--rl-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-eyebrow {
|
||||||
|
margin: 0 0 .45rem;
|
||||||
|
color: var(--rl-accent-strong);
|
||||||
|
font-size: .72rem;
|
||||||
|
font-weight: 850;
|
||||||
|
letter-spacing: .14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-hero__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: .6rem;
|
||||||
|
min-width: min(100%, 22rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-vault-picker {
|
||||||
|
display: grid;
|
||||||
|
gap: .35rem;
|
||||||
|
flex: 1;
|
||||||
|
color: var(--rl-muted);
|
||||||
|
font-size: .72rem;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-app button,
|
||||||
|
.rl-app input,
|
||||||
|
.rl-app select,
|
||||||
|
.rl-dialog button,
|
||||||
|
.rl-dialog input,
|
||||||
|
.rl-dialog select,
|
||||||
|
.rl-dialog textarea {
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid var(--rl-line);
|
||||||
|
border-radius: 9px;
|
||||||
|
color: var(--rl-ink);
|
||||||
|
background: var(--surface);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-app button,
|
||||||
|
.rl-dialog button {
|
||||||
|
min-height: 2.55rem;
|
||||||
|
padding: .55rem .85rem;
|
||||||
|
font-size: .86rem;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-app button:hover,
|
||||||
|
.rl-dialog button:hover,
|
||||||
|
.rl-app button:focus-visible,
|
||||||
|
.rl-dialog button:focus-visible {
|
||||||
|
border-color: var(--rl-accent);
|
||||||
|
background: color-mix(in oklab, var(--rl-accent) 9%, var(--surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-app :focus-visible,
|
||||||
|
.rl-dialog :focus-visible {
|
||||||
|
outline: 3px solid color-mix(in oklab, var(--rl-accent) 38%, transparent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-app .rl-primary,
|
||||||
|
.rl-dialog .rl-primary {
|
||||||
|
border-color: var(--rl-accent-strong);
|
||||||
|
color: #fffaf2;
|
||||||
|
background: var(--rl-accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-app .rl-primary:hover,
|
||||||
|
.rl-dialog .rl-primary:hover {
|
||||||
|
color: white;
|
||||||
|
background: color-mix(in oklab, var(--rl-accent-strong) 88%, #000);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-icon-button { white-space: nowrap; }
|
||||||
|
|
||||||
|
.rl-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr)) minmax(8rem, 1.4fr);
|
||||||
|
border-inline: 1px solid var(--rl-line);
|
||||||
|
border-bottom: 1px solid var(--rl-line);
|
||||||
|
background: var(--rl-paper-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-summary > div {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: .45rem;
|
||||||
|
padding: .9rem 1.15rem;
|
||||||
|
border-right: 1px solid var(--rl-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-summary > div:last-child { border-right: 0; }
|
||||||
|
.rl-summary span { font-family: Georgia, serif; font-size: 1.55rem; font-weight: 800; color: var(--heading); }
|
||||||
|
.rl-summary small { color: var(--rl-muted); font-size: .72rem; font-weight: 750; text-transform: uppercase; letter-spacing: .06em; }
|
||||||
|
.rl-summary .rl-summary__privacy { justify-content: flex-end; }
|
||||||
|
.rl-summary__privacy span { color: #4a8a5b; font-size: .8rem; }
|
||||||
|
.rl-summary__privacy[data-state="syncing"] span { color: #a87831; }
|
||||||
|
.rl-summary__privacy[data-state="offline"] span { color: #a13f35; }
|
||||||
|
.rl-summary__privacy[data-state="error"] span { color: #a13f35; }
|
||||||
|
|
||||||
|
.rl-command-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .55rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-inline: 1px solid var(--rl-line);
|
||||||
|
border-bottom: 1px solid var(--rl-line);
|
||||||
|
background: color-mix(in oklab, var(--surface) 88%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-workspace {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 15.5rem minmax(0, 1fr);
|
||||||
|
min-height: 36rem;
|
||||||
|
border-inline: 1px solid var(--rl-line);
|
||||||
|
border-bottom: 1px solid var(--rl-line);
|
||||||
|
border-radius: 0 0 18px 18px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--rl-paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-sidebar {
|
||||||
|
padding: 1.3rem .9rem;
|
||||||
|
border-right: 1px solid var(--rl-line);
|
||||||
|
background: color-mix(in oklab, var(--rl-paper-deep) 72%, var(--surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-sidebar__head,
|
||||||
|
.rl-library__toolbar,
|
||||||
|
.rl-sessions__head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-sidebar h2,
|
||||||
|
.rl-library h2 { font-size: 1.4rem; }
|
||||||
|
|
||||||
|
.rl-folder-list {
|
||||||
|
display: grid;
|
||||||
|
gap: .25rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-folder-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .25rem; }
|
||||||
|
.rl-folder-row > button:first-child {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
width: 100%;
|
||||||
|
border-color: transparent;
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.rl-folder-row > button:first-child span:last-child { color: var(--rl-muted); font-size: .75rem; }
|
||||||
|
.rl-folder-row.is-active > button:first-child { border-color: var(--rl-line); color: var(--heading); background: var(--surface); box-shadow: 0 5px 12px color-mix(in oklab, #000 6%, transparent); }
|
||||||
|
.rl-folder-menu { min-width: 2.45rem; padding-inline: .55rem !important; }
|
||||||
|
.rl-folder-rule { height: 1px; margin: .55rem .4rem; background: var(--rl-line); }
|
||||||
|
|
||||||
|
.rl-library { min-width: 0; padding: 1.35rem; }
|
||||||
|
.rl-library__toolbar { margin-bottom: 1rem; }
|
||||||
|
.rl-view-toggle { display: flex; gap: .2rem; padding: .2rem; border: 1px solid var(--rl-line); border-radius: 10px; }
|
||||||
|
.rl-view-toggle button { min-height: 2rem; padding: .3rem .65rem; border-color: transparent; background: transparent; }
|
||||||
|
.rl-view-toggle button.is-active { background: var(--rl-paper-deep); color: var(--heading); }
|
||||||
|
|
||||||
|
.rl-filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(12rem, 2fr) repeat(4, minmax(7rem, 1fr));
|
||||||
|
gap: .55rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.rl-filters input,
|
||||||
|
.rl-filters select,
|
||||||
|
.rl-vault-picker select { width: 100%; min-height: 2.65rem; padding: .55rem .7rem; }
|
||||||
|
|
||||||
|
.rl-resource-list { display: grid; gap: 1.15rem; min-width: 0; }
|
||||||
|
.rl-status-group {
|
||||||
|
--rl-status-colour: var(--rl-accent);
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--rl-line);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: color-mix(in oklab, var(--surface) 94%, var(--rl-status-colour) 6%);
|
||||||
|
}
|
||||||
|
.rl-status-group--active { --rl-status-colour: #3f7f5d; }
|
||||||
|
.rl-status-group--backlog { --rl-status-colour: #9b6b43; }
|
||||||
|
.rl-status-group--shelf { --rl-status-colour: #52769a; }
|
||||||
|
.rl-status-group--completed { --rl-status-colour: #7162a2; }
|
||||||
|
.rl-status-group--archived { --rl-status-colour: #77736d; }
|
||||||
|
.rl-status-group__head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: .7rem minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: .7rem;
|
||||||
|
padding: .75rem .85rem;
|
||||||
|
border-bottom: 1px solid var(--rl-line);
|
||||||
|
background: color-mix(in oklab, var(--surface) 82%, var(--rl-status-colour) 18%);
|
||||||
|
}
|
||||||
|
.rl-status-group__marker { width: .62rem; height: .62rem; border-radius: 50%; background: var(--rl-status-colour); box-shadow: 0 0 0 4px color-mix(in oklab, var(--rl-status-colour) 18%, transparent); }
|
||||||
|
.rl-status-group__title { min-width: 0; }
|
||||||
|
.rl-status-group__title strong { display: block; color: var(--heading); font-family: Georgia, serif; font-size: 1rem; }
|
||||||
|
.rl-status-group__title small { display: block; margin-top: .12rem; overflow: hidden; color: var(--rl-muted); font-size: .72rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.rl-status-group__count { min-width: 1.75rem; padding: .18rem .45rem; border-radius: 999px; color: var(--heading); background: color-mix(in oklab, var(--surface) 76%, transparent); font-size: .72rem; font-weight: 850; text-align: center; }
|
||||||
|
.rl-status-resources { display: grid; gap: .6rem; min-width: 0; padding: .65rem; }
|
||||||
|
.rl-resource-list.is-grid .rl-status-resources { grid-template-columns: repeat(auto-fit, minmax(min(13.5rem, 100%), 1fr)); }
|
||||||
|
|
||||||
|
.rl-resource-card {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 3.5rem minmax(0, 1.25fr) minmax(8rem, .8fr) minmax(8rem, .65fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: .9rem;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
padding: .65rem;
|
||||||
|
border: 1px solid var(--rl-line);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: var(--rl-ink);
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: 0 5px 14px color-mix(in oklab, #000 5%, transparent);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.rl-resource-card > * { min-width: 0; }
|
||||||
|
.rl-resource-card:hover { transform: translateY(-1px); border-color: var(--rl-accent); }
|
||||||
|
.rl-resource-cover {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 3.5rem;
|
||||||
|
aspect-ratio: 3 / 4;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 7px;
|
||||||
|
color: color-mix(in oklab, var(--rl-accent-strong) 70%, white);
|
||||||
|
background: linear-gradient(135deg, var(--rl-accent-strong), color-mix(in oklab, var(--rl-accent) 40%, var(--surface)));
|
||||||
|
font-family: Georgia, serif;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.rl-resource-cover img { width: 100%; height: 100%; object-fit: cover; border-radius: 0 !important; }
|
||||||
|
.rl-resource-title { min-width: 0; }
|
||||||
|
.rl-resource-title strong { display: block; overflow: hidden; color: var(--heading); font-family: Georgia, serif; font-size: 1rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.rl-resource-title small,
|
||||||
|
.rl-resource-meta { display: block; margin-top: .25rem; color: var(--rl-muted); font-size: .76rem; }
|
||||||
|
.rl-badges { display: flex; flex-wrap: wrap; gap: .3rem; }
|
||||||
|
.rl-badge { padding: .22rem .42rem; border-radius: 999px; color: var(--rl-accent-strong); background: color-mix(in oklab, var(--rl-accent) 13%, var(--surface)); font-size: .68rem; font-weight: 800; text-transform: capitalize; }
|
||||||
|
.rl-progress { min-width: 7rem; }
|
||||||
|
.rl-progress__track { height: .42rem; overflow: hidden; border-radius: 999px; background: var(--rl-paper-deep); }
|
||||||
|
.rl-progress__fill { display: block; height: 100%; border-radius: inherit; background: var(--rl-accent-strong); }
|
||||||
|
.rl-progress small { display: block; margin-top: .3rem; color: var(--rl-muted); font-size: .7rem; text-align: right; }
|
||||||
|
.rl-card-arrow { color: var(--rl-muted); font-size: 1.2rem; }
|
||||||
|
|
||||||
|
.rl-resource-list.is-grid .rl-resource-card { grid-template-columns: 3.8rem minmax(0, 1fr); align-items: start; min-height: 9rem; height: auto; }
|
||||||
|
.rl-resource-list.is-grid .rl-badges,
|
||||||
|
.rl-resource-list.is-grid .rl-progress { grid-column: 1 / -1; }
|
||||||
|
.rl-resource-list.is-grid .rl-card-arrow { display: none; }
|
||||||
|
|
||||||
|
.rl-empty { padding: 4rem 1.5rem; border: 1px dashed var(--rl-line); border-radius: 14px; text-align: center; background: color-mix(in oklab, var(--surface) 58%, transparent); }
|
||||||
|
.rl-empty__mark { display: block; margin-bottom: .8rem; color: var(--rl-accent); font-family: Georgia, serif; font-size: 3rem; }
|
||||||
|
.rl-empty h3 { margin: 0; color: var(--heading); font-family: Georgia, serif; }
|
||||||
|
.rl-empty p { max-width: 32rem; margin: .5rem auto 1rem; color: var(--rl-muted); }
|
||||||
|
|
||||||
|
.rl-dialog { width: min(60rem, calc(100% - 2rem)); max-height: calc(100vh - 2rem); padding: 0; border: 1px solid var(--rl-line); border-radius: 16px; color: var(--rl-ink); background: var(--surface); box-shadow: 0 24px 80px rgba(0,0,0,.3); }
|
||||||
|
.rl-dialog::backdrop { background: rgba(12, 10, 8, .62); backdrop-filter: blur(3px); }
|
||||||
|
.rl-dialog__shell { display: flex; flex-direction: column; max-height: calc(100vh - 2rem); }
|
||||||
|
.rl-dialog__shell--small { width: min(32rem, 100%); margin: auto; }
|
||||||
|
.rl-dialog__head,
|
||||||
|
.rl-dialog__foot { display: flex; align-items: center; gap: .6rem; padding: 1rem 1.2rem; border-bottom: 1px solid var(--rl-line); background: var(--rl-paper); }
|
||||||
|
.rl-dialog__head { justify-content: space-between; }
|
||||||
|
.rl-dialog__foot { justify-content: flex-end; border-top: 1px solid var(--rl-line); border-bottom: 0; }
|
||||||
|
.rl-dialog__body { overflow: auto; padding: 1.2rem; }
|
||||||
|
.rl-dialog__spacer { flex: 1; }
|
||||||
|
.rl-close { width: 2.55rem; padding: 0 !important; font-size: 1.45rem !important; }
|
||||||
|
.rl-danger { color: #a12d2d !important; border-color: color-mix(in oklab, #a12d2d 45%, var(--rl-line)) !important; }
|
||||||
|
|
||||||
|
.rl-resource-dialog .rl-dialog__body { display: grid; grid-template-columns: 8rem minmax(0, 1fr); gap: 1.2rem; }
|
||||||
|
.rl-resource-dialog.is-compact { width: min(46rem, calc(100% - 2rem)); }
|
||||||
|
.rl-resource-dialog.is-compact .rl-dialog__body { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.rl-resource-dialog.is-compact .rl-form-grid { max-width: 42rem; width: 100%; margin-inline: auto; }
|
||||||
|
.rl-cover-preview { display: grid; place-items: center; align-self: start; aspect-ratio: 3/4; overflow: hidden; border: 1px solid var(--rl-line); border-radius: 10px; color: var(--rl-muted); background: var(--rl-paper-deep); font-family: Georgia, serif; }
|
||||||
|
.rl-cover-preview img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.rl-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .85rem; }
|
||||||
|
.rl-field { display: grid; gap: .35rem; color: var(--rl-muted); font-size: .78rem; font-weight: 750; }
|
||||||
|
.rl-field--wide { grid-column: 1 / -1; }
|
||||||
|
.rl-field input,
|
||||||
|
.rl-field select,
|
||||||
|
.rl-field textarea { width: 100%; min-height: 2.55rem; padding: .55rem .65rem; resize: vertical; color: var(--rl-ink); }
|
||||||
|
.rl-sessions { grid-column: 1 / -1; padding-top: 1rem; border-top: 1px solid var(--rl-line); }
|
||||||
|
.rl-session-row { display: grid; grid-template-columns: 7rem minmax(8rem, auto) minmax(0, 1fr) auto; gap: .7rem; align-items: center; padding: .55rem 0; border-bottom: 1px solid var(--rl-line); font-size: .82rem; }
|
||||||
|
.rl-session-row small { color: var(--rl-muted); }
|
||||||
|
.rl-session-row button { min-height: 2rem; padding: .25rem .55rem; }
|
||||||
|
|
||||||
|
.rl-vault-row { display: grid; grid-template-columns: .8rem minmax(0, 1fr) auto auto; gap: .5rem; align-items: center; padding: .65rem 0; border-bottom: 1px solid var(--rl-line); }
|
||||||
|
.rl-vault-dot { width: .7rem; height: .7rem; border-radius: 50%; background: var(--vault-colour); }
|
||||||
|
.rl-import-summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: .6rem; }
|
||||||
|
.rl-import-summary div { padding: .8rem; border-radius: 10px; background: var(--rl-paper-deep); text-align: center; }
|
||||||
|
.rl-import-summary strong { display: block; color: var(--heading); font-family: Georgia, serif; font-size: 1.6rem; }
|
||||||
|
.rl-help { color: var(--rl-muted); font-size: .82rem; }
|
||||||
|
|
||||||
|
.rl-loading { display: none; position: fixed; inset: 0; z-index: 1000; place-items: center; align-content: center; color: white; background: rgba(12, 10, 8, .68); }
|
||||||
|
.rl-loading.is-visible { display: grid; }
|
||||||
|
.rl-loading span { width: 2.3rem; height: 2.3rem; border: 3px solid rgba(255,255,255,.3); border-top-color: white; border-radius: 50%; animation: rl-spin .8s linear infinite; }
|
||||||
|
.rl-loading p { margin: .8rem 0; }
|
||||||
|
@keyframes rl-spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.rl-toast { position: fixed; right: 1.2rem; bottom: 1.2rem; z-index: 1100; max-width: min(25rem, calc(100vw - 2.4rem)); padding: .8rem 1rem; border: 1px solid var(--rl-line); border-radius: 10px; color: var(--rl-ink); background: var(--surface); box-shadow: 0 16px 45px rgba(0,0,0,.22); opacity: 0; transform: translateY(1rem); pointer-events: none; transition: .2s ease; }
|
||||||
|
.rl-toast.is-visible { opacity: 1; transform: translateY(0); }
|
||||||
|
.rl-toast.is-error { border-color: #a12d2d; }
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.rl-hero { align-items: stretch; flex-direction: column; }
|
||||||
|
.rl-summary { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
.rl-summary__privacy { grid-column: 1 / -1; justify-content: flex-start !important; border-top: 1px solid var(--rl-line); }
|
||||||
|
.rl-workspace { grid-template-columns: 1fr; }
|
||||||
|
.rl-sidebar { border-right: 0; border-bottom: 1px solid var(--rl-line); }
|
||||||
|
.rl-folder-list { display: flex; overflow-x: auto; padding-bottom: .25rem; }
|
||||||
|
.rl-folder-row { flex: 0 0 auto; }
|
||||||
|
.rl-folder-row > button:first-child { width: auto; }
|
||||||
|
.rl-folder-rule { width: 1px; height: auto; margin: .3rem; }
|
||||||
|
.rl-filters { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
.rl-search { grid-column: 1 / -1; }
|
||||||
|
.rl-resource-card { grid-template-columns: 3.5rem minmax(0, 1fr) auto; }
|
||||||
|
.rl-resource-card .rl-badges,
|
||||||
|
.rl-resource-card .rl-progress { grid-column: 2 / -1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.rl-hero { padding: 1.4rem 1rem; border-radius: 12px 12px 0 0; }
|
||||||
|
.rl-hero__actions { align-items: stretch; flex-direction: column; min-width: 0; }
|
||||||
|
.rl-summary { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.rl-summary > div:nth-child(2) { border-right: 0; }
|
||||||
|
.rl-summary > div:nth-child(-n+2) { border-bottom: 1px solid var(--rl-line); }
|
||||||
|
.rl-command-bar button { flex: 1 1 8rem; }
|
||||||
|
.rl-library { padding: 1rem .7rem; }
|
||||||
|
.rl-library__toolbar { align-items: flex-start; }
|
||||||
|
.rl-filters { grid-template-columns: 1fr; }
|
||||||
|
.rl-search { grid-column: auto; }
|
||||||
|
.rl-resource-list.is-grid .rl-status-resources { grid-template-columns: 1fr; }
|
||||||
|
.rl-status-group__title small { white-space: normal; }
|
||||||
|
.rl-resource-card { grid-template-columns: 3.5rem minmax(0, 1fr); }
|
||||||
|
.rl-resource-card .rl-badges,
|
||||||
|
.rl-resource-card .rl-progress { grid-column: 1 / -1; }
|
||||||
|
.rl-card-arrow { display: none; }
|
||||||
|
.rl-resource-dialog .rl-dialog__body { grid-template-columns: 1fr; }
|
||||||
|
.rl-cover-preview { width: 7rem; }
|
||||||
|
.rl-form-grid { grid-template-columns: 1fr; }
|
||||||
|
.rl-field--wide { grid-column: auto; }
|
||||||
|
.rl-dialog__foot { flex-wrap: wrap; }
|
||||||
|
.rl-dialog__spacer { display: none; }
|
||||||
|
.rl-session-row { grid-template-columns: 1fr auto; }
|
||||||
|
.rl-session-row span:nth-child(3) { grid-column: 1 / -1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.rl-resource-card,
|
||||||
|
.rl-toast { transition: none; }
|
||||||
|
.rl-loading span { animation: none; }
|
||||||
|
}
|
||||||
0
assets/styles/pages/wird-tracker.css
Normal file → Executable file
0
assets/styles/play.css
Normal file → Executable file
0
assets/styles/themes/tokens.css
Normal file → Executable file
0
assets/styles/utilities/responsive.css
Normal file → Executable file
0
assets/styles/vendor/bigger-picture.min.css
vendored
Normal file → Executable file
0
backups/hidden-details/20260511-163316-hidden-details.js
Normal file → Executable file
0
backups/hidden-details/20260511-163316-hidden-details.json
Normal file → Executable file
0
backups/hidden-details/20260511-232803-hidden-details.js
Normal file → Executable file
0
backups/hidden-details/20260511-232803-hidden-details.json
Normal file → Executable file
0
backups/hidden-details/20260511-233454-hidden-details.js
Normal file → Executable file
0
backups/hidden-details/20260511-233454-hidden-details.json
Normal file → Executable file
0
backups/hidden-details/20260511-233534-hidden-details.js
Normal file → Executable file
0
backups/hidden-details/20260511-233534-hidden-details.json
Normal file → Executable file
0
backups/hidden-details/20260513-150101-hidden-details.js
Normal file → Executable file
0
backups/hidden-details/20260513-150101-hidden-details.json
Normal file → Executable file
0
backups/hidden-details/20260513-150424-hidden-details.js
Normal file → Executable file
0
backups/hidden-details/20260513-150424-hidden-details.json
Normal file → Executable file
0
backups/hidden-details/20260513-150530-hidden-details.js
Normal file → Executable file
0
backups/hidden-details/20260513-150530-hidden-details.json
Normal file → Executable file
22
blogs/2025/2025-list.org
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
#+TITLE: 2025 Writing
|
||||||
|
#+OPTIONS: num:nil toc:nil
|
||||||
|
#+NO_SIDENOTES: t
|
||||||
|
#+DATE: <2025-12-31 Wed>
|
||||||
|
|
||||||
|
* 2025 archive
|
||||||
|
|
||||||
|
- [[file:12-december/28-12-week-review.org][28 December — Week review]]
|
||||||
|
- [[file:12-december/21-12-week-review.org][21 December — Week review]]
|
||||||
|
- [[file:12-december/14-12-week-review.org][14 December — Week review]]
|
||||||
|
- [[file:12-december/07-12-week-review.org][7 December — Week review]]
|
||||||
|
- [[file:11-november/30-11-week-review.org][30 November — Week review]]
|
||||||
|
- [[file:11-november/23-11-week-review.org][23 November — Week review]]
|
||||||
|
- [[file:11-november/16-11-week-review.org][16 November — Week review]]
|
||||||
|
- [[file:11-november/09-11-week-review.org][9 November — Week review]]
|
||||||
|
- [[file:11-november/02-11-week-review.org][2 November — Week review]]
|
||||||
|
- [[file:08-august/zettelkasten.org][Zettelkasten]]
|
||||||
|
- [[file:08-august/benefits-of-reading.org][Benefits of reading]]
|
||||||
|
- [[file:08-august/spending-the-whole-day-on-this-website.org][Spending the whole day on this website]]
|
||||||
|
- [[file:08-august/wacom-with-arch.org][Wacom with Arch]]
|
||||||
|
- [[file:08-august/what-do-i-want-to-do-with-emacs.org][What do I want to do with Emacs?]]
|
||||||
|
- [[file:08-august/hilberts.hotel.org][Hilbert's Hotel]]
|
||||||