Compare commits
13 Commits
5844a870ce
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| d719b53404 | |||
| f870a12cd5 | |||
| 0b62cd50f3 | |||
| fde4eff12d | |||
| 4f14b39d16 | |||
| 204e89dbce | |||
| 5525a03179 | |||
| 11cae7df36 | |||
| 7be8bec446 | |||
| de3abed6d7 | |||
| 4e24f876c9 | |||
| 0478bc6305 | |||
| f85c29ba42 |
@@ -8,3 +8,6 @@ datascape
|
|||||||
*.exe
|
*.exe
|
||||||
bin/
|
bin/
|
||||||
companion/datascape-companion-*
|
companion/datascape-companion-*
|
||||||
|
|
||||||
|
# Editor build tooling deps (the built bundle is committed; node_modules is not)
|
||||||
|
editor-build/node_modules/
|
||||||
|
|||||||
@@ -16,12 +16,35 @@ go build .
|
|||||||
make deploy
|
make deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Editor bundle (the one build-pipeline exception)
|
||||||
|
|
||||||
|
The page editor uses CodeMirror 6, vendored as a single pre-built IIFE at
|
||||||
|
`assets/editor/vendor/codemirror.bundle.js` and embedded via `embed.FS`. This is
|
||||||
|
the **only** deliberate exception to the "no build pipeline" rule below — it is a
|
||||||
|
one-time, committed artifact, not a runtime build. `go build` / `make deploy`
|
||||||
|
never touch Node and only consume the committed bundle.
|
||||||
|
|
||||||
|
Regenerate the bundle **only** when upgrading the `@codemirror/*` versions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# bump versions in editor-build/package.json first, then:
|
||||||
|
make editor # runs `npm ci && npm run build` in editor-build/, rewrites the vendored bundle
|
||||||
|
```
|
||||||
|
|
||||||
|
Commit the regenerated `codemirror.bundle.js` and the updated
|
||||||
|
`editor-build/package-lock.json`. `editor-build/node_modules/` is gitignored.
|
||||||
|
|
||||||
|
The bundle is served immutable under a stable filename, so the edit template
|
||||||
|
appends `?v=<content-hash>` to its `<script>` src (`editorBundleVersion` in
|
||||||
|
`main.go`). The hash changes whenever the bundle bytes change, so a rebuilt
|
||||||
|
bundle busts client caches automatically — no manual version bump needed.
|
||||||
|
|
||||||
## HTTP API Surface
|
## HTTP API Surface
|
||||||
|
|
||||||
| Method | Path | Behaviour |
|
| Method | Path | Behaviour |
|
||||||
|--------|------|-----------|
|
|--------|------|-----------|
|
||||||
| GET | `/{path}/` | If folder exists: render `index.md` + list contents. If not: show empty create prompt. |
|
| GET | `/{path}/` | If folder exists: render `index.md` + list contents. If not: show empty create prompt. |
|
||||||
| GET | `/{path}/?edit` | Mobile-friendly editor with `index.md` content in a textarea |
|
| GET | `/{path}/?edit` | CodeMirror 6 editor initialized with `index.md` content |
|
||||||
| POST | `/{path}` | Write `index.md` to disk; creates the folder if it does not exist yet |
|
| POST | `/{path}` | Write `index.md` to disk; creates the folder if it does not exist yet |
|
||||||
|
|
||||||
Non-existent paths without a trailing slash redirect to the slash form (GET only — POSTs
|
Non-existent paths without a trailing slash redirect to the slash form (GET only — POSTs
|
||||||
@@ -49,7 +72,7 @@ Prefer separate, human-readable `.html` files over inlined HTML strings in Go. E
|
|||||||
|
|
||||||
## Frontend Rules
|
## Frontend Rules
|
||||||
|
|
||||||
- Vanilla JS only — no frameworks, no build pipeline
|
- Vanilla JS only — no frameworks, no build pipeline (the single exception is the vendored CodeMirror editor bundle; see Build & Deploy)
|
||||||
- Each feature gets its own JS file; global behaviour goes in `global-shortcuts.js`
|
- Each feature gets its own JS file; global behaviour goes in `global-shortcuts.js`
|
||||||
- Do not inline JS in templates or merge unrelated features into one file
|
- Do not inline JS in templates or merge unrelated features into one file
|
||||||
- `ALT+SHIFT` is the modifier for all keyboard shortcuts — do not introduce others
|
- `ALT+SHIFT` is the modifier for all keyboard shortcuts — do not introduce others
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ COMPANION_WIN := companion/datascape-companion-windows-amd64.exe
|
|||||||
COMPANION_LIN := companion/datascape-companion-linux-amd64
|
COMPANION_LIN := companion/datascape-companion-linux-amd64
|
||||||
COMPANION_SRCS := $(wildcard cmd/companion/*.go) $(wildcard cmd/companion/*.html) go.mod go.sum
|
COMPANION_SRCS := $(wildcard cmd/companion/*.go) $(wildcard cmd/companion/*.html) go.mod go.sum
|
||||||
|
|
||||||
.PHONY: deploy companion companion-windows companion-linux companion-release
|
EDITOR_BUNDLE := assets/editor/vendor/codemirror.bundle.js
|
||||||
|
EDITOR_SRCS := $(wildcard editor-build/*.js) editor-build/package.json editor-build/package-lock.json
|
||||||
|
|
||||||
|
.PHONY: deploy companion companion-windows companion-linux companion-release editor
|
||||||
|
|
||||||
# Cross-compiled companion artifacts the wiki binary embeds. Both must exist
|
# Cross-compiled companion artifacts the wiki binary embeds. Both must exist
|
||||||
# before `go build .` so embed.FS picks them up.
|
# before `go build .` so embed.FS picks them up.
|
||||||
@@ -24,6 +27,15 @@ companion:
|
|||||||
mkdir -p bin
|
mkdir -p bin
|
||||||
go build -o bin/ ./cmd/companion
|
go build -o bin/ ./cmd/companion
|
||||||
|
|
||||||
|
# Regenerate the vendored CodeMirror bundle. One-time/dev-only step: run after
|
||||||
|
# upgrading the @codemirror/* versions in editor-build/package.json. The built
|
||||||
|
# artifact ($(EDITOR_BUNDLE)) is committed; `go build` only consumes it and
|
||||||
|
# never runs Node.
|
||||||
|
editor: $(EDITOR_BUNDLE)
|
||||||
|
|
||||||
|
$(EDITOR_BUNDLE): $(EDITOR_SRCS)
|
||||||
|
cd editor-build && npm ci && npm run build
|
||||||
|
|
||||||
deploy: companion-release
|
deploy: companion-release
|
||||||
GOOS=linux GOARCH=arm GOARM=7 go build -o datascape-arm .
|
GOOS=linux GOARCH=arm GOARM=7 go build -o datascape-arm .
|
||||||
ssh $(NAS) 'kill $$(cat /share/homes/luxick/.local/bin/datascape.pid) 2>/dev/null; rm -f /share/homes/luxick/.local/bin/datascape.pid'
|
ssh $(NAS) 'kill $$(cat /share/homes/luxick/.local/bin/datascape.pid) 2>/dev/null; rm -f /share/homes/luxick/.local/bin/datascape.pid'
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ Minimal self-hosted personal wiki. Folders are pages.
|
|||||||
|
|
||||||
- **Pages** every folder is a page. Place an `index.md` inside a folder and it renders as HTML. Drop any other files (PDFs, images, etc.) alongside it and they appear in the listing below the content. Navigating to a path that does not exist shows a **[CREATE]** prompt.
|
- **Pages** every folder is a page. Place an `index.md` inside a folder and it renders as HTML. Drop any other files (PDFs, images, etc.) alongside it and they appear in the listing below the content. Navigating to a path that does not exist shows a **[CREATE]** prompt.
|
||||||
|
|
||||||
|
- **View settings** per folder, display the file listing as a list or thumbnail grid and pick the sort key/order, via the **view** button in the `Files` header. See the [View Settings](#view-settings) section.
|
||||||
|
|
||||||
- **Search** search across all page names (folder names) in the wiki, accessible from the navigation bar.
|
- **Search** search across all page names (folder names) in the wiki, accessible from the navigation bar.
|
||||||
|
|
||||||
- **Wikilinks** link between pages with `[[Page Name]]` syntax. When a page is renamed or moved, all wikilinks pointing to it are rewritten automatically to reflect the new path.
|
- **Wikilinks** link between pages with `[[Page Name]]` syntax. When a page is renamed or moved, all wikilinks pointing to it are rewritten automatically to reflect the new path.
|
||||||
@@ -42,9 +44,22 @@ go run . -dir ./wiki -addr :8080 -user me -pass secret
|
|||||||
| `-pass` | _(none)_ | Basic auth password |
|
| `-pass` | _(none)_ | Basic auth password |
|
||||||
| `-reindex-interval` | `30m` | Periodic search index rebuild interval (`0` disables) |
|
| `-reindex-interval` | `30m` | Periodic search index rebuild interval (`0` disables) |
|
||||||
|
|
||||||
|
## View Settings
|
||||||
|
|
||||||
|
The **view** button in a folder's `Files` header sets how its listing renders,
|
||||||
|
persisting three keys to `.page-settings`:
|
||||||
|
|
||||||
|
| Key | Values (default first) |
|
||||||
|
|------|------------------------|
|
||||||
|
| `view` | `list`, `thumbnail` |
|
||||||
|
| `sort` | `name`, `modified`, `size` (folders always sort by name, grouped first) |
|
||||||
|
| `order` | `asc`, `desc` |
|
||||||
|
|
||||||
## Special Folder Types
|
## Special Folder Types
|
||||||
|
|
||||||
A folder can opt into special rendering by adding a `.page-settings` file:
|
A folder can opt into special rendering by adding a `.page-settings` file. The
|
||||||
|
same file also holds the [View Settings](#view-settings) keys; only the `type`
|
||||||
|
key selects a special renderer:
|
||||||
|
|
||||||
```
|
```
|
||||||
type = diary
|
type = diary
|
||||||
|
|||||||
+6
-4
@@ -115,13 +115,15 @@
|
|||||||
function wireFileLinks() {
|
function wireFileLinks() {
|
||||||
if (!state.available) return;
|
if (!state.available) return;
|
||||||
document.addEventListener('click', function (e) {
|
document.addEventListener('click', function (e) {
|
||||||
var item = e.target.closest && e.target.closest('.list-item');
|
if (!e.target.closest) return;
|
||||||
if (!item) return;
|
// Match both listing styles: table rows expose the file link inside
|
||||||
var anchor = e.target.closest('a');
|
// a .list-item row; thumbnail tiles are bare a.thumb-tile anchors.
|
||||||
|
var anchor = e.target.closest('.list-item a, a.thumb-tile');
|
||||||
if (!anchor) return;
|
if (!anchor) return;
|
||||||
|
var item = anchor.closest('.list-item');
|
||||||
// Only intercept the primary file link, and only for files (not folders).
|
// Only intercept the primary file link, and only for files (not folders).
|
||||||
// Folders end with "/" — let the browser navigate normally.
|
// Folders end with "/" — let the browser navigate normally.
|
||||||
var path = item.dataset.path || anchor.getAttribute('href');
|
var path = (item && item.dataset.path) || anchor.getAttribute('href');
|
||||||
if (!path || path.endsWith('/')) return;
|
if (!path || path.endsWith('/')) return;
|
||||||
// Allow modified clicks (open in new tab, etc.) to pass through.
|
// Allow modified clicks (open in new tab, etc.) to pass through.
|
||||||
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
window.EditorLists = (function () {
|
|
||||||
|
|
||||||
function detectListPrefix(lineText) {
|
|
||||||
var m;
|
|
||||||
m = lineText.match(/^(\s*)(- \[[ x]\] )/);
|
|
||||||
if (m) return { indent: m[1], prefix: m[2], type: 'task' };
|
|
||||||
m = lineText.match(/^(\s*)([-*+] )/);
|
|
||||||
if (m) return { indent: m[1], prefix: m[2], type: 'unordered' };
|
|
||||||
m = lineText.match(/^(\s*)(\d+)\. /);
|
|
||||||
if (m) return { indent: m[1], prefix: m[2] + '. ', type: 'ordered', num: parseInt(m[2], 10) };
|
|
||||||
m = lineText.match(/^(\s*)(> )/);
|
|
||||||
if (m) return { indent: m[1], prefix: m[2], type: 'blockquote' };
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function continuationPrefix(info) {
|
|
||||||
if (info.type === 'task') return info.indent + '- [ ] ';
|
|
||||||
if (info.type === 'ordered') return info.indent + (info.num + 1) + '. ';
|
|
||||||
return info.indent + info.prefix;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renumberOrderedList(text, fromLineIndex, indent, startNum) {
|
|
||||||
var lines = text.split('\n');
|
|
||||||
var re = new RegExp('^' + indent.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(\\d+)\\. ');
|
|
||||||
var num = startNum !== undefined ? startNum : null;
|
|
||||||
for (var i = fromLineIndex; i < lines.length; i++) {
|
|
||||||
var m = lines[i].match(re);
|
|
||||||
if (!m) break;
|
|
||||||
if (num === null) num = parseInt(m[1], 10);
|
|
||||||
var newNumStr = String(num);
|
|
||||||
if (m[1] !== newNumStr) {
|
|
||||||
lines[i] = indent + newNumStr + '. ' + lines[i].slice(m[0].length);
|
|
||||||
}
|
|
||||||
num++;
|
|
||||||
}
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEnterKey(text, cursorPos) {
|
|
||||||
var before = text.slice(0, cursorPos);
|
|
||||||
var after = text.slice(cursorPos);
|
|
||||||
var lineStart = before.lastIndexOf('\n') + 1;
|
|
||||||
var lineEnd = text.indexOf('\n', cursorPos);
|
|
||||||
if (lineEnd === -1) lineEnd = text.length;
|
|
||||||
var fullLine = text.slice(lineStart, lineEnd);
|
|
||||||
var info = detectListPrefix(fullLine);
|
|
||||||
if (!info) return null;
|
|
||||||
|
|
||||||
var contentAfterPrefix = fullLine.slice(info.indent.length + info.prefix.length);
|
|
||||||
if (contentAfterPrefix.trim() === '') {
|
|
||||||
var newText = text.slice(0, lineStart) + '\n' + after;
|
|
||||||
var newCursor = lineStart + 1;
|
|
||||||
if (info.type === 'ordered') {
|
|
||||||
var lineIndex = text.slice(0, lineStart).split('\n').length;
|
|
||||||
newText = renumberOrderedList(newText, lineIndex, info.indent);
|
|
||||||
}
|
|
||||||
return { text: newText, cursor: newCursor };
|
|
||||||
}
|
|
||||||
|
|
||||||
var cont = continuationPrefix(info);
|
|
||||||
var newText = before + '\n' + cont + after;
|
|
||||||
var newCursor = cursorPos + 1 + cont.length;
|
|
||||||
if (info.type === 'ordered') {
|
|
||||||
var insertedLineIndex = before.split('\n').length;
|
|
||||||
newText = renumberOrderedList(newText, insertedLineIndex, info.indent);
|
|
||||||
}
|
|
||||||
return { text: newText, cursor: newCursor };
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteOrderedLine(text, cursorPos) {
|
|
||||||
var lineStart = text.lastIndexOf('\n', cursorPos - 1) + 1;
|
|
||||||
var lineEnd = text.indexOf('\n', cursorPos);
|
|
||||||
if (lineEnd === -1) lineEnd = text.length;
|
|
||||||
var fullLine = text.slice(lineStart, lineEnd);
|
|
||||||
var info = detectListPrefix(fullLine);
|
|
||||||
if (!info || info.type !== 'ordered') return null;
|
|
||||||
|
|
||||||
var newText = text.slice(0, lineStart) + text.slice(lineEnd === text.length ? lineEnd : lineEnd + 1);
|
|
||||||
var newCursor = lineStart;
|
|
||||||
var fromLineIndex = text.slice(0, lineStart).split('\n').length - 1;
|
|
||||||
newText = renumberOrderedList(newText, fromLineIndex, info.indent, info.num);
|
|
||||||
return { text: newText, cursor: Math.min(newCursor, newText.length) };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { handleEnterKey: handleEnterKey, deleteOrderedLine: deleteOrderedLine };
|
|
||||||
})();
|
|
||||||
@@ -5,12 +5,16 @@
|
|||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<script>
|
<script>
|
||||||
if (sessionStorage.getItem('editor-wide') === '1') document.body.classList.add('editor-wide');
|
document.body.classList.add('edit-mode');
|
||||||
</script>
|
</script>
|
||||||
<form id="edit-form" class="edit-form" method="POST" action="{{.PostURL}}">
|
<form id="edit-form" class="edit-form" method="POST" action="{{.PostURL}}">
|
||||||
{{if ge .SectionIndex 0}}<input type="hidden" name="section" value="{{.SectionIndex}}">{{end}}
|
{{if ge .SectionIndex 0}}<input type="hidden" name="section" value="{{.SectionIndex}}">{{end}}
|
||||||
{{if ge .InsertBefore 0}}<input type="hidden" name="insert_before" value="{{.InsertBefore}}">{{end}}
|
{{if ge .InsertBefore 0}}<input type="hidden" name="insert_before" value="{{.InsertBefore}}">{{end}}
|
||||||
<div class="editor-toolbar">
|
<div class="editor-toolbar">
|
||||||
|
<button type="button" class="btn btn-tool" data-action="undo" title="Undo">↶</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="redo" title="Redo">↷</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="deleteline" data-key="Y" title="Delete line (Y)">×</button>
|
||||||
|
<span class="toolbar-sep"></span>
|
||||||
<button type="button" class="btn btn-tool" data-action="bold" data-key="B" title="Bold (B)">**</button>
|
<button type="button" class="btn btn-tool" data-action="bold" data-key="B" title="Bold (B)">**</button>
|
||||||
<button type="button" class="btn btn-tool" data-action="italic" data-key="I" title="Italic (I)">*</button>
|
<button type="button" class="btn btn-tool" data-action="italic" data-key="I" title="Italic (I)">*</button>
|
||||||
<span class="dropdown">
|
<span class="dropdown">
|
||||||
@@ -68,14 +72,14 @@
|
|||||||
<button type="button" class="btn btn-tool btn-block" data-action="movie" data-key="V" title="Import movie (V)">Import movie</button>
|
<button type="button" class="btn btn-tool btn-block" data-action="movie" data-key="V" title="Import movie (V)">Import movie</button>
|
||||||
</div>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
<span class="toolbar-sep"></span>
|
|
||||||
<button type="button" class="btn btn-tool" data-action="wide" data-key="Z" title="Toggle wide mode (Z)">⇔</button>
|
|
||||||
</div>
|
</div>
|
||||||
<textarea class="input editor-textarea" name="content" id="editor" autofocus>{{.RawContent}}</textarea>
|
<div id="editor" class="editor-cm"></div>
|
||||||
|
<textarea name="content" id="editor-content" hidden>{{.RawContent}}</textarea>
|
||||||
</form>
|
</form>
|
||||||
<script src="/_/editor/lists.js"></script>
|
<script src="/_/editor/vendor/codemirror.bundle.js?v={{editorBundleVersion}}"></script>
|
||||||
<script src="/_/editor/tables.js"></script>
|
<script src="/_/editor/tables.js"></script>
|
||||||
<script src="/_/editor/dates.js"></script>
|
<script src="/_/editor/dates.js"></script>
|
||||||
<script src="/_/editor/movie.js"></script>
|
<script src="/_/editor/movie.js"></script>
|
||||||
|
<script src="/_/editor/wikicomplete.js"></script>
|
||||||
<script src="/_/editor/main.js"></script>
|
<script src="/_/editor/main.js"></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
+213
-118
@@ -1,71 +1,183 @@
|
|||||||
(function () {
|
(function () {
|
||||||
var textarea = document.getElementById('editor');
|
var mount = document.getElementById('editor');
|
||||||
if (!textarea) return;
|
var hidden = document.getElementById('editor-content');
|
||||||
|
if (!mount || !hidden || !window.CM) return;
|
||||||
|
|
||||||
var form = textarea.closest('form');
|
var form = hidden.closest('form');
|
||||||
|
|
||||||
// --- DOM helpers ---
|
var T = EditorTables;
|
||||||
|
var D = EditorDates;
|
||||||
|
var M = EditorMovie;
|
||||||
|
|
||||||
// Route every edit through execCommand so the browser's native undo/redo
|
// --- CodeMirror setup ---
|
||||||
// stack is preserved. Direct assignment to textarea.value would wipe it.
|
|
||||||
function replaceRange(start, end, text) {
|
// Shift+Enter (new table row below) / Shift+Delete (delete table row) run at
|
||||||
textarea.focus();
|
// highest precedence so they win over CM's default newline/forward-delete.
|
||||||
textarea.selectionStart = start;
|
// Returning false (no table at cursor) lets CM fall back to its default.
|
||||||
textarea.selectionEnd = end;
|
function tableKey(fn) {
|
||||||
document.execCommand('insertText', false, text);
|
return function (view) {
|
||||||
|
var result = fn(view.state.doc.toString(), view.state.selection.main.head);
|
||||||
|
if (!result) return false;
|
||||||
|
dispatchFullReplace(result);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function wrap(before, after, placeholder) {
|
var tableKeymap = [
|
||||||
var start = textarea.selectionStart;
|
{ key: 'Shift-Enter', run: tableKey(T.insertRowBelow) },
|
||||||
var end = textarea.selectionEnd;
|
{ key: 'Shift-Delete', run: tableKey(T.deleteRow) },
|
||||||
var hadSelection = end > start;
|
];
|
||||||
var selected = hadSelection ? textarea.value.slice(start, end) : placeholder;
|
|
||||||
replaceRange(start, end, before + selected + after);
|
|
||||||
if (!hadSelection) {
|
|
||||||
textarea.selectionStart = start + before.length;
|
|
||||||
textarea.selectionEnd = start + before.length + placeholder.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function linePrefix(prefix) {
|
var state = CM.EditorState.create({
|
||||||
var start = textarea.selectionStart;
|
doc: hidden.value,
|
||||||
var lineStart = textarea.value.lastIndexOf('\n', start - 1) + 1;
|
extensions: [
|
||||||
replaceRange(lineStart, lineStart, prefix);
|
CM.history(),
|
||||||
textarea.selectionStart = textarea.selectionEnd = start + prefix.length;
|
CM.drawSelection(),
|
||||||
|
CM.indentOnInput(),
|
||||||
|
CM.EditorView.lineWrapping,
|
||||||
|
// Enable native browser spellcheck on the contenteditable surface
|
||||||
|
// (CM6 leaves it off by default). autocapitalize helps prose entry
|
||||||
|
// on the Android/mobile path; CM's DOM observer absorbs corrections.
|
||||||
|
CM.EditorView.contentAttributes.of({ spellcheck: 'true', autocapitalize: 'sentences' }),
|
||||||
|
CM.markdown({ base: CM.markdownLanguage }),
|
||||||
|
CM.syntaxHighlighting(CM.highlightStyle),
|
||||||
|
CM.closeBrackets(),
|
||||||
|
CM.autocompletion({ override: [WikiComplete.source] }),
|
||||||
|
CM.theme,
|
||||||
|
CM.Prec.highest(CM.keymap.of(tableKeymap)),
|
||||||
|
CM.keymap.of([].concat(
|
||||||
|
CM.closeBracketsKeymap,
|
||||||
|
CM.completionKeymap,
|
||||||
|
CM.markdownKeymap,
|
||||||
|
CM.defaultKeymap,
|
||||||
|
CM.historyKeymap,
|
||||||
|
[CM.indentWithTab]
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
var view = new CM.EditorView({ state: state, parent: mount });
|
||||||
|
view.focus();
|
||||||
|
|
||||||
|
// --- CM document helpers ---
|
||||||
|
|
||||||
|
function dispatchFullReplace(result) {
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: 0, to: view.state.doc.length, insert: result.text },
|
||||||
|
selection: { anchor: result.cursor },
|
||||||
|
scrollIntoView: true,
|
||||||
|
});
|
||||||
|
view.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertAtCursor(s) {
|
function insertAtCursor(s) {
|
||||||
replaceRange(textarea.selectionStart, textarea.selectionEnd, s);
|
var sel = view.state.selection.main;
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: sel.from, to: sel.to, insert: s },
|
||||||
|
selection: { anchor: sel.from + s.length },
|
||||||
|
scrollIntoView: true,
|
||||||
|
});
|
||||||
|
view.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyResult(result) {
|
function wrap(before, after, placeholder) {
|
||||||
var oldText = textarea.value;
|
var sel = view.state.selection.main;
|
||||||
var newText = result.text;
|
var hadSelection = sel.to > sel.from;
|
||||||
var prefixLen = 0;
|
var selected = hadSelection ? view.state.sliceDoc(sel.from, sel.to) : placeholder;
|
||||||
var maxPrefix = Math.min(oldText.length, newText.length);
|
var insert = before + selected + after;
|
||||||
while (prefixLen < maxPrefix && oldText.charCodeAt(prefixLen) === newText.charCodeAt(prefixLen)) {
|
var anchor, head;
|
||||||
prefixLen++;
|
if (hadSelection) {
|
||||||
|
anchor = head = sel.from + insert.length;
|
||||||
|
} else {
|
||||||
|
anchor = sel.from + before.length;
|
||||||
|
head = anchor + placeholder.length;
|
||||||
}
|
}
|
||||||
var oldEnd = oldText.length;
|
view.dispatch({
|
||||||
var newEnd = newText.length;
|
changes: { from: sel.from, to: sel.to, insert: insert },
|
||||||
while (oldEnd > prefixLen && newEnd > prefixLen
|
selection: { anchor: anchor, head: head },
|
||||||
&& oldText.charCodeAt(oldEnd - 1) === newText.charCodeAt(newEnd - 1)) {
|
scrollIntoView: true,
|
||||||
oldEnd--;
|
});
|
||||||
newEnd--;
|
view.focus();
|
||||||
}
|
}
|
||||||
replaceRange(prefixLen, oldEnd, newText.slice(prefixLen, newEnd));
|
|
||||||
textarea.selectionStart = textarea.selectionEnd = result.cursor;
|
function linePrefix(prefix) {
|
||||||
|
var sel = view.state.selection.main;
|
||||||
|
var line = view.state.doc.lineAt(sel.from);
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: line.from, to: line.from, insert: prefix },
|
||||||
|
selection: { anchor: sel.from + prefix.length },
|
||||||
|
scrollIntoView: true,
|
||||||
|
});
|
||||||
|
view.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTableOp(fn, arg) {
|
function applyTableOp(fn, arg) {
|
||||||
var result = arg !== undefined
|
var text = view.state.doc.toString();
|
||||||
? fn(textarea.value, textarea.selectionStart, arg)
|
var pos = view.state.selection.main.head;
|
||||||
: fn(textarea.value, textarea.selectionStart);
|
var result = arg !== undefined ? fn(text, pos, arg) : fn(text, pos);
|
||||||
if (result) applyResult(result);
|
if (result) dispatchFullReplace(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Adapter passed to movie.js so it reads/writes the CM document instead of a
|
||||||
|
// textarea (replace() dispatches a transaction; cursor lands after the block).
|
||||||
|
var movieCtx = {
|
||||||
|
getValue: function () { return view.state.doc.toString(); },
|
||||||
|
replace: function (start, end, text) {
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: start, to: end, insert: text },
|
||||||
|
selection: { anchor: start + text.length },
|
||||||
|
scrollIntoView: true,
|
||||||
|
});
|
||||||
|
view.focus();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Content sync ---
|
||||||
|
|
||||||
|
// Serialize the CM document into the hidden textarea on submit only — Save
|
||||||
|
// must not depend on CM focus or async state (decision 8). requestSubmit
|
||||||
|
// fires this listener even for the ALT+SHIFT+S path.
|
||||||
|
function syncContent() {
|
||||||
|
hidden.value = view.state.doc.toString();
|
||||||
|
}
|
||||||
|
form.addEventListener('submit', syncContent);
|
||||||
|
|
||||||
|
// --- Actions ---
|
||||||
|
|
||||||
|
var actions = {
|
||||||
|
save: function () { form.requestSubmit(); },
|
||||||
|
undo: function () { CM.undo(view); view.focus(); },
|
||||||
|
redo: function () { CM.redo(view); view.focus(); },
|
||||||
|
deleteline: function () { CM.deleteLine(view); view.focus(); },
|
||||||
|
bold: function () { wrap('**', '**', 'bold text'); },
|
||||||
|
italic: function () { wrap('*', '*', 'italic text'); },
|
||||||
|
h1: function () { linePrefix('# '); },
|
||||||
|
h2: function () { linePrefix('## '); },
|
||||||
|
h3: function () { linePrefix('### '); },
|
||||||
|
code: function () { wrap('`', '`', 'code'); },
|
||||||
|
codeblock: function () { wrap('```\n', '\n```', 'code'); },
|
||||||
|
quote: function () { linePrefix('> '); },
|
||||||
|
link: function () { wrap('[', '](url)', 'link text'); },
|
||||||
|
wikilink: insertWikilink,
|
||||||
|
ul: function () { linePrefix('- '); },
|
||||||
|
ol: function () { linePrefix('1. '); },
|
||||||
|
task: function () { linePrefix('- [ ] '); },
|
||||||
|
hr: function () { wrap('\n\n---\n\n', '', ''); },
|
||||||
|
fmttable: function () { applyTableOp(T.formatTableText); },
|
||||||
|
tblalignleft: function () { applyTableOp(T.setColumnAlignment, 'left'); },
|
||||||
|
tblaligncenter: function () { applyTableOp(T.setColumnAlignment, 'center'); },
|
||||||
|
tblalignright: function () { applyTableOp(T.setColumnAlignment, 'right'); },
|
||||||
|
tblinsertcol: function () { applyTableOp(T.insertColumn); },
|
||||||
|
tbldeletecol: function () { applyTableOp(T.deleteColumn); },
|
||||||
|
tblinsertrow: function () { applyTableOp(T.insertRow); },
|
||||||
|
tbldeleterow: function () { applyTableOp(T.deleteRow); },
|
||||||
|
dateiso: function () { insertAtCursor(D.isoDate()); },
|
||||||
|
datelong: function () { insertAtCursor(D.longDate()); },
|
||||||
|
movie: function () { M.run(movieCtx); },
|
||||||
|
};
|
||||||
|
|
||||||
// isValidWikiTarget mirrors the Go validator in wikilinks.go — absolute
|
// isValidWikiTarget mirrors the Go validator in wikilinks.go — absolute
|
||||||
// path, no empty/dot segments. Used to gate the INSERT confirm button.
|
// path, no empty/dot segments. Used to gate the modal's INSERT button.
|
||||||
function isValidWikiTarget(p) {
|
function isValidWikiTarget(p) {
|
||||||
if (!p || p[0] !== '/') return false;
|
if (!p || p[0] !== '/') return false;
|
||||||
var trimmed = p.replace(/^\/+|\/+$/g, '');
|
var trimmed = p.replace(/^\/+|\/+$/g, '');
|
||||||
@@ -77,8 +189,13 @@
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wiki link button (ALT+SHIFT+P): open a modal with a target field backed by
|
||||||
|
// full /_search typeahead plus an optional display-text field, then insert
|
||||||
|
// [[target]] or [[target::display]] at the cursor. (Inline `[[` typing uses
|
||||||
|
// the folder-scoped completion in wikicomplete.js instead.)
|
||||||
function insertWikilink() {
|
function insertWikilink() {
|
||||||
var sel = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd);
|
var sel = view.state.selection.main;
|
||||||
|
var selectedText = view.state.sliceDoc(sel.from, sel.to);
|
||||||
|
|
||||||
var container = document.createElement('div');
|
var container = document.createElement('div');
|
||||||
|
|
||||||
@@ -93,7 +210,7 @@
|
|||||||
displayInput.type = 'text';
|
displayInput.type = 'text';
|
||||||
displayInput.className = 'input';
|
displayInput.className = 'input';
|
||||||
displayInput.placeholder = 'Display text (optional)';
|
displayInput.placeholder = 'Display text (optional)';
|
||||||
if (sel) displayInput.value = sel;
|
if (selectedText) displayInput.value = selectedText;
|
||||||
|
|
||||||
container.appendChild(targetWrap);
|
container.appendChild(targetWrap);
|
||||||
container.appendChild(displayInput);
|
container.appendChild(displayInput);
|
||||||
@@ -131,47 +248,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Actions ---
|
|
||||||
|
|
||||||
var T = EditorTables;
|
|
||||||
var L = EditorLists;
|
|
||||||
var D = EditorDates;
|
|
||||||
var M = EditorMovie;
|
|
||||||
|
|
||||||
var actions = {
|
|
||||||
save: function () { form.submit(); },
|
|
||||||
bold: function () { wrap('**', '**', 'bold text'); },
|
|
||||||
italic: function () { wrap('*', '*', 'italic text'); },
|
|
||||||
h1: function () { linePrefix('# '); },
|
|
||||||
h2: function () { linePrefix('## '); },
|
|
||||||
h3: function () { linePrefix('### '); },
|
|
||||||
code: function () { wrap('`', '`', 'code'); },
|
|
||||||
codeblock: function () { wrap('```\n', '\n```', 'code'); },
|
|
||||||
quote: function () { linePrefix('> '); },
|
|
||||||
link: function () { wrap('[', '](url)', 'link text'); },
|
|
||||||
wikilink: insertWikilink,
|
|
||||||
ul: function () { linePrefix('- '); },
|
|
||||||
ol: function () { linePrefix('1. '); },
|
|
||||||
task: function () { linePrefix('- [ ] '); },
|
|
||||||
hr: function () { wrap('\n\n---\n\n', '', ''); },
|
|
||||||
fmttable: function () { applyTableOp(T.formatTableText); },
|
|
||||||
tblalignleft: function () { applyTableOp(T.setColumnAlignment, 'left'); },
|
|
||||||
tblaligncenter: function () { applyTableOp(T.setColumnAlignment, 'center'); },
|
|
||||||
tblalignright: function () { applyTableOp(T.setColumnAlignment, 'right'); },
|
|
||||||
tblinsertcol: function () { applyTableOp(T.insertColumn); },
|
|
||||||
tbldeletecol: function () { applyTableOp(T.deleteColumn); },
|
|
||||||
tblinsertrow: function () { applyTableOp(T.insertRow); },
|
|
||||||
tbldeleterow: function () { applyTableOp(T.deleteRow); },
|
|
||||||
dateiso: function () { insertAtCursor(D.isoDate()); },
|
|
||||||
datelong: function () { insertAtCursor(D.longDate()); },
|
|
||||||
movie: function () { M.run(textarea); },
|
|
||||||
wide: function () {
|
|
||||||
var enabled = !document.body.classList.contains('editor-wide');
|
|
||||||
document.body.classList.toggle('editor-wide', enabled);
|
|
||||||
sessionStorage.setItem('editor-wide', enabled ? '1' : '0');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Keyboard shortcut registration ---
|
// --- Keyboard shortcut registration ---
|
||||||
|
|
||||||
var keyMap = {};
|
var keyMap = {};
|
||||||
@@ -185,6 +261,19 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Keep the editor focused when a toolbar button is tapped. Without this the
|
||||||
|
// button steals focus on mousedown, which dismisses the mobile soft keyboard
|
||||||
|
// before the action runs (and view.focus() can't reopen it without a direct
|
||||||
|
// gesture). preventDefault on mousedown blocks the focus shift; click still
|
||||||
|
// fires. Scoped to the toolbar so header SAVE/CANCEL are unaffected. Includes
|
||||||
|
// dropdown toggles, which also must not pull focus off the editor.
|
||||||
|
var toolbar = document.querySelector('.editor-toolbar');
|
||||||
|
if (toolbar) {
|
||||||
|
toolbar.addEventListener('mousedown', function (e) {
|
||||||
|
if (e.target.closest('.btn')) e.preventDefault();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', function (e) {
|
document.addEventListener('keydown', function (e) {
|
||||||
if (!e.altKey || !e.shiftKey) return;
|
if (!e.altKey || !e.shiftKey) return;
|
||||||
// Shift+digit produces a layout-dependent character in e.key (e.g. "!"
|
// Shift+digit produces a layout-dependent character in e.key (e.g. "!"
|
||||||
@@ -197,32 +286,38 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Textarea key handling ---
|
|
||||||
|
|
||||||
textarea.addEventListener('keydown', function (e) {
|
|
||||||
if (e.key === 'Delete' && e.shiftKey) {
|
|
||||||
var result = T.deleteRow(textarea.value, textarea.selectionStart)
|
|
||||||
|| L.deleteOrderedLine(textarea.value, textarea.selectionStart);
|
|
||||||
if (!result) return;
|
|
||||||
e.preventDefault();
|
|
||||||
applyResult(result);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === 'Enter' && e.shiftKey) {
|
|
||||||
var result = T.insertRowBelow(textarea.value, textarea.selectionStart);
|
|
||||||
if (!result) return;
|
|
||||||
e.preventDefault();
|
|
||||||
applyResult(result);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key !== 'Enter') return;
|
|
||||||
var result = L.handleEnterKey(textarea.value, textarea.selectionStart);
|
|
||||||
if (!result) return;
|
|
||||||
e.preventDefault();
|
|
||||||
applyResult(result);
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Dropdowns ---
|
// --- Dropdowns ---
|
||||||
|
|
||||||
document.querySelectorAll('.dropdown-toggle').forEach(wireDropdown);
|
// The toolbar scrolls horizontally (so it clips its absolutely-positioned
|
||||||
|
// menus) and on mobile is fixed to the bottom of the viewport. Pin an open
|
||||||
|
// menu to the viewport so it escapes the clip, opening upward when there
|
||||||
|
// isn't room below it (the bottom-toolbar case).
|
||||||
|
function pinMenu(toggle, menu) {
|
||||||
|
if (!menu.classList.contains('is-open')) return;
|
||||||
|
var r = toggle.getBoundingClientRect();
|
||||||
|
var vh = window.innerHeight;
|
||||||
|
menu.style.position = 'fixed';
|
||||||
|
menu.style.overflowY = 'auto';
|
||||||
|
var spaceBelow = vh - r.bottom;
|
||||||
|
var spaceAbove = r.top;
|
||||||
|
if (spaceBelow < menu.offsetHeight + 8 && spaceAbove > spaceBelow) {
|
||||||
|
menu.style.top = 'auto';
|
||||||
|
menu.style.bottom = (vh - r.top) + 'px';
|
||||||
|
menu.style.maxHeight = (spaceAbove - 8) + 'px';
|
||||||
|
} else {
|
||||||
|
menu.style.bottom = 'auto';
|
||||||
|
menu.style.top = r.bottom + 'px';
|
||||||
|
menu.style.maxHeight = (spaceBelow - 8) + 'px';
|
||||||
|
}
|
||||||
|
var left = Math.min(r.left, document.documentElement.clientWidth - menu.offsetWidth - 4);
|
||||||
|
menu.style.left = Math.max(4, left) + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.dropdown-toggle').forEach(function (toggle) {
|
||||||
|
wireDropdown(toggle);
|
||||||
|
var menu = toggle.parentElement.querySelector('.dropdown-menu');
|
||||||
|
if (!menu || !toolbar || !toolbar.contains(toggle)) return;
|
||||||
|
// Runs after wireDropdown's own click handler has toggled is-open.
|
||||||
|
toggle.addEventListener('click', function () { pinMenu(toggle, menu); });
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
+13
-19
@@ -48,26 +48,20 @@ window.EditorMovie = (function () {
|
|||||||
return out.join('\n');
|
return out.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function replaceRange(ta, start, end, text) {
|
// ctx is the CM adapter from main.js: { getValue(), replace(start,end,text) }.
|
||||||
ta.focus();
|
function insertOrReplace(ctx, markup) {
|
||||||
ta.selectionStart = start;
|
var t = ctx.getValue() || '';
|
||||||
ta.selectionEnd = end;
|
|
||||||
document.execCommand('insertText', false, text);
|
|
||||||
}
|
|
||||||
|
|
||||||
function insertOrReplace(ta, markup) {
|
|
||||||
var t = ta.value || '';
|
|
||||||
var b = t.indexOf(BEGIN);
|
var b = t.indexOf(BEGIN);
|
||||||
var e = t.indexOf(END);
|
var e = t.indexOf(END);
|
||||||
if (b !== -1 && e !== -1 && e > b) {
|
if (b !== -1 && e !== -1 && e > b) {
|
||||||
replaceRange(ta, b, e + END.length, markup);
|
ctx.replace(b, e + END.length, markup);
|
||||||
} else {
|
} else {
|
||||||
var h = t.match(/^#{1,6}\s+.+?\s*$/m);
|
var h = t.match(/^#{1,6}\s+.+?\s*$/m);
|
||||||
if (h) {
|
if (h) {
|
||||||
var idx = t.indexOf(h[0]) + h[0].length;
|
var idx = t.indexOf(h[0]) + h[0].length;
|
||||||
replaceRange(ta, idx, idx, '\n\n' + markup);
|
ctx.replace(idx, idx, '\n\n' + markup);
|
||||||
} else {
|
} else {
|
||||||
replaceRange(ta, 0, 0, t ? markup + '\n\n' : markup);
|
ctx.replace(0, 0, t ? markup + '\n\n' : markup);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,7 +119,7 @@ window.EditorMovie = (function () {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function importWithKey(textarea, key, initialTitle) {
|
function importWithKey(ctx, key, initialTitle) {
|
||||||
var input = document.createElement('input');
|
var input = document.createElement('input');
|
||||||
input.type = 'text';
|
input.type = 'text';
|
||||||
input.className = 'input';
|
input.className = 'input';
|
||||||
@@ -148,7 +142,7 @@ window.EditorMovie = (function () {
|
|||||||
data.Error === 'Invalid API key!') {
|
data.Error === 'Invalid API key!') {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
promptForKey(true, function (newKey) {
|
promptForKey(true, function (newKey) {
|
||||||
importWithKey(textarea, newKey, raw);
|
importWithKey(ctx, newKey, raw);
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -157,7 +151,7 @@ window.EditorMovie = (function () {
|
|||||||
(data && data.Error) || 'Movie not found.');
|
(data && data.Error) || 'Movie not found.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
insertOrReplace(textarea, buildBlock(data));
|
insertOrReplace(ctx, buildBlock(data));
|
||||||
})
|
})
|
||||||
.catch(function () {
|
.catch(function () {
|
||||||
showMessage('Import failed', 'OMDb lookup failed.');
|
showMessage('Import failed', 'OMDb lookup failed.');
|
||||||
@@ -167,16 +161,16 @@ window.EditorMovie = (function () {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function run(textarea) {
|
function run(ctx) {
|
||||||
var initialTitle = firstHeading(textarea.value || '');
|
var initialTitle = firstHeading(ctx.getValue() || '');
|
||||||
var key = localStorage.getItem(STORAGE_KEY);
|
var key = localStorage.getItem(STORAGE_KEY);
|
||||||
if (!key) {
|
if (!key) {
|
||||||
promptForKey(false, function (newKey) {
|
promptForKey(false, function (newKey) {
|
||||||
importWithKey(textarea, newKey, initialTitle);
|
importWithKey(ctx, newKey, initialTitle);
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
importWithKey(textarea, key, initialTitle);
|
importWithKey(ctx, key, initialTitle);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { run: run };
|
return { run: run };
|
||||||
|
|||||||
+27
File diff suppressed because one or more lines are too long
@@ -0,0 +1,115 @@
|
|||||||
|
// wikicomplete.js — the `[[` wikilink autocomplete source for CodeMirror.
|
||||||
|
//
|
||||||
|
// A level-by-level folder/file browser scoped to the path typed so far. It
|
||||||
|
// fires only once the `[[` token's content begins with `/` (targets are
|
||||||
|
// absolute; free-text search lives in the toolbar modal instead). The content
|
||||||
|
// is split into a parent path (up to and including the last `/`) and a partial
|
||||||
|
// segment (the text after it); the parent's children are fetched from the
|
||||||
|
// existing `?tree=1` endpoint and filtered to names containing the partial
|
||||||
|
// (case-insensitive substring).
|
||||||
|
//
|
||||||
|
// Picking a folder inserts `<name>/` and re-opens the popup to drill one level
|
||||||
|
// deeper; picking a file inserts `<name>` and stops. Only the current segment
|
||||||
|
// is replaced, so the trailing `]]` stays put and the cursor parks before it,
|
||||||
|
// leaving room to type a `::display` alias. Exposes window.WikiComplete.source
|
||||||
|
// for main.js to register via CM's autocompletion().
|
||||||
|
window.WikiComplete = (function () {
|
||||||
|
// treeURL builds the `?tree=1` request URL for an absolute parent path,
|
||||||
|
// percent-encoding each segment. A leading/trailing slash is tolerated;
|
||||||
|
// root resolves to `/?tree=1`.
|
||||||
|
function treeURL(parent) {
|
||||||
|
var trimmed = parent.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||||
|
if (trimmed === '') return '/?tree=1';
|
||||||
|
var enc = trimmed.split('/').map(encodeURIComponent).join('/');
|
||||||
|
return '/' + enc + '/?tree=1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchTree(parent) {
|
||||||
|
return fetch(treeURL(parent), {
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
}).then(function (r) {
|
||||||
|
// A 404 means the parent folder doesn't exist (typo, or a path under
|
||||||
|
// a file) — treat it as "no completions", not an error.
|
||||||
|
if (r.status === 404) return null;
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeApply builds the apply() for a chosen entry. It replaces only the
|
||||||
|
// current segment ([from, to]); the trailing `]]` is untouched, so the
|
||||||
|
// cursor ends up parked before it. Folders append `/` and re-open the popup
|
||||||
|
// to drill into the next level; files terminate.
|
||||||
|
function makeApply(name, kind) {
|
||||||
|
return function (view, completion, from, to) {
|
||||||
|
var isFolder = kind === 'folder';
|
||||||
|
var insert = isFolder ? name + '/' : name;
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: from, to: to, insert: insert },
|
||||||
|
selection: { anchor: from + insert.length },
|
||||||
|
scrollIntoView: true,
|
||||||
|
});
|
||||||
|
if (isFolder) {
|
||||||
|
// Re-open after the transaction so the completion plugin sees
|
||||||
|
// the updated document (the next level's parent path).
|
||||||
|
setTimeout(function () { CM.startCompletion(view); }, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// CM completion source. Activates when `[[` is followed by content that
|
||||||
|
// begins with `/`. The content is split at its last `/` into a parent path
|
||||||
|
// and a partial segment; the parent's children are fetched, filtered to
|
||||||
|
// names containing the partial (case-insensitive substring), and offered
|
||||||
|
// with name-only labels.
|
||||||
|
function source(context) {
|
||||||
|
var match = context.matchBefore(/\[\[[^\]\n]*/);
|
||||||
|
if (!match) return null;
|
||||||
|
var content = context.state.sliceDoc(match.from + 2, context.pos);
|
||||||
|
if (content[0] !== '/') return null;
|
||||||
|
|
||||||
|
var lastSlash = content.lastIndexOf('/');
|
||||||
|
var parent = content.slice(0, lastSlash + 1);
|
||||||
|
var partial = content.slice(lastSlash + 1);
|
||||||
|
|
||||||
|
// Replace only the current segment: from the start of the partial up to
|
||||||
|
// the next `/` or `]` (or end of line). This narrows re-edits inside an
|
||||||
|
// existing `[[…]]` so drilling doesn't duplicate trailing text.
|
||||||
|
var from = match.from + 2 + lastSlash + 1;
|
||||||
|
var line = context.state.doc.lineAt(context.pos);
|
||||||
|
var to = context.pos;
|
||||||
|
while (to < line.to) {
|
||||||
|
var ch = context.state.sliceDoc(to, to + 1);
|
||||||
|
if (ch === '/' || ch === ']') break;
|
||||||
|
to++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise(function (resolve) {
|
||||||
|
if (context.aborted) { resolve(null); return; }
|
||||||
|
fetchTree(parent).then(function (resp) {
|
||||||
|
if (context.aborted || !resp) { resolve(null); return; }
|
||||||
|
var needle = partial.toLowerCase();
|
||||||
|
var options = (resp.entries || []).reduce(function (acc, e) {
|
||||||
|
if (needle && e.name.toLowerCase().indexOf(needle) === -1) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
acc.push({
|
||||||
|
label: e.name,
|
||||||
|
type: e.kind === 'folder' ? 'folder' : 'file',
|
||||||
|
apply: makeApply(e.name, e.kind),
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
// No validFor: the source re-runs on each keystroke, so every
|
||||||
|
// edit (more chars, backspace, or a `/` that drills into the
|
||||||
|
// next folder) re-fetches and re-filters from scratch.
|
||||||
|
resolve({ from: from, to: to, options: options });
|
||||||
|
}).catch(function () {
|
||||||
|
resolve(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { source: source };
|
||||||
|
})();
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Fitness dashboard range dropdowns: changing one reloads the page with that
|
||||||
|
// chart's query parameter updated. Plain GET navigation — each range is a
|
||||||
|
// distinct, bookmarkable view, so no postReplace/history handling is needed.
|
||||||
|
document.addEventListener('change', function (e) {
|
||||||
|
var sel = e.target.closest('[data-fitness-range]');
|
||||||
|
if (!sel) return;
|
||||||
|
var url = new URL(window.location.href);
|
||||||
|
url.searchParams.set(sel.dataset.fitnessRange, sel.value);
|
||||||
|
window.location.href = url.toString();
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{{define "fitnessChart"}}
|
||||||
|
<section class="fitness-chart panel">
|
||||||
|
<div class="fitness-chart-header row space-between">
|
||||||
|
<span class="caption">{{.Title}}</span>
|
||||||
|
<select class="input fitness-range" data-fitness-range="{{.Param}}" aria-label="{{.Title}} time range">
|
||||||
|
{{range .Options}}<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{{if .Empty}}
|
||||||
|
<p class="fitness-empty is-empty">No data in this range.</p>
|
||||||
|
{{else}}
|
||||||
|
<svg class="fitness-svg" viewBox="0 0 {{.ViewW}} {{.ViewH}}" role="img" aria-label="{{.Title}}">
|
||||||
|
{{range .YTicks}}
|
||||||
|
<line class="chart-grid" x1="{{$.PlotX}}" y1="{{.Pos}}" x2="{{$.PlotR}}" y2="{{.Pos}}"/>
|
||||||
|
<text class="chart-label" x="{{$.YLabelX}}" y="{{.Pos}}" text-anchor="end" dominant-baseline="middle">{{.Label}}</text>
|
||||||
|
{{end}}
|
||||||
|
{{range .XTicks}}
|
||||||
|
<text class="chart-label" x="{{.Pos}}" y="{{$.XLabelY}}" text-anchor="{{.Anchor}}">{{.Label}}</text>
|
||||||
|
{{end}}
|
||||||
|
<line class="chart-axis" x1="{{.PlotX}}" y1="{{.PlotY}}" x2="{{.PlotX}}" y2="{{.PlotB}}"/>
|
||||||
|
<line class="chart-axis" x1="{{.PlotX}}" y1="{{.PlotB}}" x2="{{.PlotR}}" y2="{{.PlotB}}"/>
|
||||||
|
{{range .Lines}}
|
||||||
|
<polyline class="chart-line" points="{{.}}"/>
|
||||||
|
{{end}}
|
||||||
|
{{range .Dots}}
|
||||||
|
<circle class="chart-dot" cx="{{.X}}" cy="{{.Y}}" r="2.5"><title>{{.Title}}</title></circle>
|
||||||
|
{{end}}
|
||||||
|
{{if .Goal}}
|
||||||
|
<line class="chart-goal" x1="{{.PlotX}}" y1="{{.Goal.Y}}" x2="{{.PlotR}}" y2="{{.Goal.Y}}"/>
|
||||||
|
<text class="chart-goal-label" x="{{.PlotR}}" y="{{.Goal.LabelY}}" text-anchor="end">{{.Goal.Label}}</text>
|
||||||
|
{{end}}
|
||||||
|
</svg>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
<div class="fitness-dash col">
|
||||||
|
{{if .Notice}}
|
||||||
|
<p class="muted">{{.Notice}}</p>
|
||||||
|
{{else}}
|
||||||
|
{{range .Charts}}{{template "fitnessChart" .}}{{end}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<script src="/_/fitness/fitness.js"></script>
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
|
||||||
<title>{{.Title}}</title>
|
<title>{{.Title}}</title>
|
||||||
<link rel="icon" href="/_/favicon.ico" />
|
<link rel="icon" href="/_/favicon.ico" />
|
||||||
<link rel="preload" href="/_/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin />
|
<link rel="preload" href="/_/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin />
|
||||||
|
|||||||
+13
-1
@@ -8,7 +8,17 @@
|
|||||||
<div class="content">{{.SpecialContent}}</div>
|
<div class="content">{{.SpecialContent}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .Entries}}
|
{{if .Entries}}
|
||||||
<h2 id="files">Files <button class="btn btn-small" data-companion-reveal hidden title="Open folder in file manager">open</button></h2>
|
<h2 id="files">Files <button class="btn btn-small" data-companion-reveal hidden title="Open folder in file manager">open</button>{{if .CanEdit}} <button class="btn btn-small" id="view-settings-btn" onclick="openViewSettings()" title="View & sorting" data-view="{{.View}}" data-sort="{{.Sort}}" data-order="{{.Order}}">view</button>{{end}}</h2>
|
||||||
|
{{if eq .View "thumbnail"}}
|
||||||
|
<div class="thumb-grid">
|
||||||
|
{{range .Entries}}
|
||||||
|
<a class="thumb-tile" href="{{.URL}}" title="{{.Name}}">
|
||||||
|
{{if .ThumbURL}}<img class="thumb-img" src="{{.ThumbURL}}" alt="" loading="lazy" width="300">{{else}}<span class="thumb-icon">{{.Icon}}</span>{{end}}
|
||||||
|
<span class="thumb-label truncate">{{.Name}}</span>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
<table class="data-table panel">
|
<table class="data-table panel">
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Entries}}
|
{{range .Entries}}
|
||||||
@@ -20,6 +30,8 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{{end}}
|
||||||
|
{{if .CanEdit}}<script src="/_/page/view-settings.js"></script>{{end}}
|
||||||
{{else if not .Content}}
|
{{else if not .Content}}
|
||||||
{{if not .SpecialContent}}
|
{{if not .SpecialContent}}
|
||||||
<p class="empty">Empty folder — <a href="?edit">[CREATE]</a></p>
|
<p class="empty">Empty folder — <a href="?edit">[CREATE]</a></p>
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// View-settings modal: lets the user pick the folder listing's view style,
|
||||||
|
// sort key, and order, then persists them by POSTing to the folder with
|
||||||
|
// ?settings. Reuses openModal/closeModal and postReplace from page/actions.js.
|
||||||
|
function openViewSettings() {
|
||||||
|
var btn = document.getElementById('view-settings-btn');
|
||||||
|
var state = {
|
||||||
|
view: (btn && btn.dataset.view) || 'list',
|
||||||
|
sort: (btn && btn.dataset.sort) || 'name',
|
||||||
|
order: (btn && btn.dataset.order) || 'asc'
|
||||||
|
};
|
||||||
|
|
||||||
|
// segmented builds a row of mutually-exclusive .btn toggles bound to a
|
||||||
|
// single state key, marking the current choice with .is-active.
|
||||||
|
function segmented(key, options) {
|
||||||
|
var wrap = document.createElement('div');
|
||||||
|
wrap.className = 'row gap-1';
|
||||||
|
options.forEach(function (opt) {
|
||||||
|
var b = document.createElement('button');
|
||||||
|
b.type = 'button';
|
||||||
|
b.className = 'btn';
|
||||||
|
b.textContent = opt.label;
|
||||||
|
if (state[key] === opt.value) b.classList.add('is-active');
|
||||||
|
b.addEventListener('click', function () {
|
||||||
|
state[key] = opt.value;
|
||||||
|
wrap.querySelectorAll('button').forEach(function (x) {
|
||||||
|
x.classList.remove('is-active');
|
||||||
|
});
|
||||||
|
b.classList.add('is-active');
|
||||||
|
});
|
||||||
|
wrap.appendChild(b);
|
||||||
|
});
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function field(labelText, control) {
|
||||||
|
var row = document.createElement('div');
|
||||||
|
row.className = 'col gap-1';
|
||||||
|
var label = document.createElement('span');
|
||||||
|
label.className = 'caption';
|
||||||
|
label.textContent = labelText;
|
||||||
|
row.appendChild(label);
|
||||||
|
row.appendChild(control);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sortSelect = document.createElement('select');
|
||||||
|
sortSelect.className = 'input';
|
||||||
|
[['name', 'Name'], ['modified', 'Modified'], ['size', 'Size']].forEach(function (o) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = o[0];
|
||||||
|
opt.textContent = o[1];
|
||||||
|
if (state.sort === o[0]) opt.selected = true;
|
||||||
|
sortSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
sortSelect.addEventListener('change', function () { state.sort = sortSelect.value; });
|
||||||
|
|
||||||
|
var body = document.createElement('div');
|
||||||
|
body.className = 'col';
|
||||||
|
body.appendChild(field('View style', segmented('view', [
|
||||||
|
{ value: 'list', label: 'List' },
|
||||||
|
{ value: 'thumbnail', label: 'Thumbnail' }
|
||||||
|
])));
|
||||||
|
body.appendChild(field('Sort by', sortSelect));
|
||||||
|
body.appendChild(field('Order', segmented('order', [
|
||||||
|
{ value: 'asc', label: 'Asc' },
|
||||||
|
{ value: 'desc', label: 'Desc' }
|
||||||
|
])));
|
||||||
|
|
||||||
|
openModal({
|
||||||
|
title: 'View settings',
|
||||||
|
body: body,
|
||||||
|
confirm: {
|
||||||
|
label: 'SAVE',
|
||||||
|
onConfirm: function () {
|
||||||
|
var action = window.location.pathname + '?settings';
|
||||||
|
var formBody = 'view=' + encodeURIComponent(state.view) +
|
||||||
|
'&sort=' + encodeURIComponent(state.sort) +
|
||||||
|
'&order=' + encodeURIComponent(state.order);
|
||||||
|
var target = window.location.pathname;
|
||||||
|
closeModal();
|
||||||
|
postReplace(action, formBody, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
+115
-16
@@ -70,6 +70,7 @@ hr { border: none; border-top: var(--border-dashed); margin: var(--space-4) 0; }
|
|||||||
.gap-2 { gap: var(--space-2); }
|
.gap-2 { gap: var(--space-2); }
|
||||||
.gap-3 { gap: var(--space-3); }
|
.gap-3 { gap: var(--space-3); }
|
||||||
.gap-4 { gap: var(--space-4); }
|
.gap-4 { gap: var(--space-4); }
|
||||||
|
.space-between { justify-content: space-between; }
|
||||||
.divider-dashed { border-bottom: var(--border-dashed); }
|
.divider-dashed { border-bottom: var(--border-dashed); }
|
||||||
|
|
||||||
/* === Page layout ===
|
/* === Page layout ===
|
||||||
@@ -166,8 +167,8 @@ footer {
|
|||||||
.btn::before { content: "["; color: var(--secondary); }
|
.btn::before { content: "["; color: var(--secondary); }
|
||||||
.btn::after { content: "]"; color: var(--secondary); }
|
.btn::after { content: "]"; color: var(--secondary); }
|
||||||
.btn:hover { color: var(--primary-hover); }
|
.btn:hover { color: var(--primary-hover); }
|
||||||
.btn-small { font-size: 0.65rem; font-weight: normal; vertical-align: middle; }
|
.btn-small { font-size: 0.8rem; font-weight: normal; vertical-align: middle; }
|
||||||
.btn-tool { font-size: var(--font-sm); padding: 0 0.15rem; }
|
.btn-tool { padding: 0 0.15rem; }
|
||||||
.btn-block {
|
.btn-block {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -192,6 +193,8 @@ footer {
|
|||||||
.btn-fab:hover { background: var(--bg-panel-hover); color: var(--primary-hover); }
|
.btn-fab:hover { background: var(--bg-panel-hover); color: var(--primary-hover); }
|
||||||
.danger { color: var(--danger); }
|
.danger { color: var(--danger); }
|
||||||
.danger:hover { color: var(--danger-hover); }
|
.danger:hover { color: var(--danger-hover); }
|
||||||
|
/* Selected segmented-toggle button (view-settings modal). */
|
||||||
|
.btn.is-active { color: var(--primary-hover); }
|
||||||
|
|
||||||
/* === Form controls ===
|
/* === Form controls ===
|
||||||
.input baseline is shared by search-input, modal inputs, and the editor
|
.input baseline is shared by search-input, modal inputs, and the editor
|
||||||
@@ -350,16 +353,24 @@ main > h2 {
|
|||||||
.suggest-path { color: var(--text-muted); font-size: 0.8rem; margin-top: 0.1rem; }
|
.suggest-path { color: var(--text-muted); font-size: 0.8rem; margin-top: 0.1rem; }
|
||||||
.suggest-footer > td { color: var(--link); font-size: var(--font-sm); }
|
.suggest-footer > td { color: var(--link); font-size: var(--font-sm); }
|
||||||
|
|
||||||
/* === Editor toolbar === */
|
/* === Editor toolbar ===
|
||||||
|
Single non-wrapping row that scrolls horizontally (swipe on mobile) rather
|
||||||
|
than breaking into stacked rows. A horizontal-scroll container also clips
|
||||||
|
overflow-y, so open dropdown menus are pinned to the viewport via JS
|
||||||
|
(editor/main.js pinMenu) to escape the clip. */
|
||||||
.editor-toolbar {
|
.editor-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
border: var(--border);
|
border: var(--border);
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
padding: 0.4rem 0.6rem;
|
padding: 0.4rem 0.6rem;
|
||||||
background: var(--bg-panel-hover);
|
background: var(--bg-panel-hover);
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
.editor-toolbar::-webkit-scrollbar { display: none; }
|
||||||
|
.editor-toolbar > * { flex-shrink: 0; }
|
||||||
.toolbar-sep {
|
.toolbar-sep {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
background: var(--secondary);
|
background: var(--secondary);
|
||||||
@@ -369,17 +380,14 @@ main > h2 {
|
|||||||
|
|
||||||
/* === Edit form === */
|
/* === Edit form === */
|
||||||
.edit-form { display: flex; flex-direction: column; }
|
.edit-form { display: flex; flex-direction: column; }
|
||||||
body.editor-wide .page-wrap { max-width: none; }
|
/* The sidebar is always empty while editing, so the editor uses the full
|
||||||
.editor-textarea {
|
viewport: drop the reserved 14rem sidebar track and the centered max-width. */
|
||||||
min-height: 60vh;
|
body.edit-mode .page-wrap { grid-template-columns: minmax(0, 1fr); max-width: none; }
|
||||||
background: var(--bg);
|
/* CodeMirror mount. The .cm-editor visual treatment (border, bg, font, padding)
|
||||||
border-top: none;
|
lives in the CM theme (editor-build/entry.js), keyed off the same :root
|
||||||
font-family: "Iosevka Slab", monospace;
|
variables; this only sizes the container. */
|
||||||
font-size: 0.9rem;
|
.editor-cm { min-height: 60vh; }
|
||||||
line-height: 1.6;
|
.editor-cm .cm-editor { height: 100%; }
|
||||||
padding: var(--space-4);
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Search === */
|
/* === Search === */
|
||||||
.search-form {
|
.search-form {
|
||||||
@@ -435,6 +443,43 @@ button.fab { display: none; }
|
|||||||
background: var(--bg-panel) url("/_/icons/thumb-placeholder.svg") center/2rem no-repeat;
|
background: var(--bg-panel) url("/_/icons/thumb-placeholder.svg") center/2rem no-repeat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Thumbnail listing grid ===
|
||||||
|
File-listing variant of .photo-grid: responsive tiles that pair a thumbnail
|
||||||
|
(or a file/folder icon for non-thumbnailable entries) with a truncated
|
||||||
|
name label beneath. */
|
||||||
|
.thumb-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
}
|
||||||
|
.thumb-tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
color: var(--text);
|
||||||
|
border: var(--border);
|
||||||
|
background: var(--bg-panel);
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
.thumb-tile:hover { background: var(--bg-panel-hover); color: var(--primary-hover); }
|
||||||
|
.thumb-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 150px;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
background: var(--bg) url("/_/icons/thumb-placeholder.svg") center/2rem no-repeat;
|
||||||
|
}
|
||||||
|
.thumb-icon {
|
||||||
|
height: 150px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 3rem;
|
||||||
|
color: var(--secondary);
|
||||||
|
}
|
||||||
|
.thumb-label { font-size: var(--font-sm); }
|
||||||
|
|
||||||
.empty { padding: var(--space-4); text-align: center; }
|
.empty { padding: var(--space-4); text-align: center; }
|
||||||
|
|
||||||
/* === Scrollbars === */
|
/* === Scrollbars === */
|
||||||
@@ -610,6 +655,28 @@ aside.sidebar:empty { display: none; }
|
|||||||
.diary-cal-grid td.cal-current a { color: var(--primary-hover); }
|
.diary-cal-grid td.cal-current a { color: var(--primary-hover); }
|
||||||
.btn-block.cal-current { color: var(--primary-hover); }
|
.btn-block.cal-current { color: var(--primary-hover); }
|
||||||
|
|
||||||
|
/* === Fitness dashboard ===
|
||||||
|
Server-rendered inline SVG charts. Geometry comes precomputed from Go;
|
||||||
|
colors and strokes are applied here via classes so the inline SVG follows
|
||||||
|
the theme palette. */
|
||||||
|
.fitness-chart { padding: var(--space-3); }
|
||||||
|
.fitness-chart-header { margin-bottom: var(--space-2); }
|
||||||
|
.fitness-range { width: auto; font-size: var(--font-sm); }
|
||||||
|
.fitness-empty {
|
||||||
|
border: var(--border-dashed);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-5);
|
||||||
|
}
|
||||||
|
.fitness-svg { display: block; width: 100%; height: auto; }
|
||||||
|
.fitness-svg .chart-grid { stroke: var(--bg-panel-hover); }
|
||||||
|
.fitness-svg .chart-axis { stroke: var(--text-muted); }
|
||||||
|
.fitness-svg .chart-label { fill: var(--text-muted); font-size: var(--font-xs); }
|
||||||
|
.fitness-svg .chart-line { fill: none; stroke: var(--link); stroke-width: 1.5; }
|
||||||
|
.fitness-svg .chart-dot { fill: var(--link); }
|
||||||
|
.fitness-svg .chart-goal { stroke: var(--primary-hover); stroke-dasharray: 4 3; }
|
||||||
|
.fitness-svg .chart-goal-label { fill: var(--primary-hover); font-size: var(--font-xs); }
|
||||||
|
|
||||||
/* === Responsive === */
|
/* === Responsive === */
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.page-wrap { grid-template-columns: 1fr; }
|
.page-wrap { grid-template-columns: 1fr; }
|
||||||
@@ -639,8 +706,40 @@ aside.sidebar:empty { display: none; }
|
|||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
header, footer { padding: var(--space-2) var(--space-3); }
|
header, footer { padding: var(--space-2) var(--space-3); }
|
||||||
main { padding: var(--space-4) var(--space-3); }
|
main { padding: var(--space-4) var(--space-3); }
|
||||||
.editor-textarea { min-height: 50vh; }
|
.editor-cm { min-height: 50vh; }
|
||||||
.sidebar { width: calc(100% - 1.5rem); }
|
.sidebar { width: calc(100% - 1.5rem); }
|
||||||
|
/* Editing on mobile is full-bleed: drop the page/main inset so the toolbar
|
||||||
|
and editor use the entire viewport width. */
|
||||||
|
body.edit-mode .page-wrap { padding: 0; gap: 0; }
|
||||||
|
body.edit-mode main { padding: 0; }
|
||||||
|
/* Fingers, not cursors: give every toolbar control a ~44px tap target. */
|
||||||
|
.editor-toolbar { gap: var(--space-2); padding: var(--space-2); }
|
||||||
|
.editor-toolbar .btn-tool {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 2rem;
|
||||||
|
min-height: 2rem;
|
||||||
|
padding: 0 var(--space-1);
|
||||||
|
}
|
||||||
|
/* Pin the toolbar above the on-screen keyboard rather than at the top, which
|
||||||
|
is out of thumb reach while typing. interactive-widget=resizes-content
|
||||||
|
(layout.html viewport) shrinks the viewport on keyboard open so bottom: 0
|
||||||
|
sits directly above it. cm-content reserves matching scroll space so the
|
||||||
|
last lines aren't hidden behind the bar. */
|
||||||
|
body.edit-mode .editor-toolbar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 50;
|
||||||
|
border: none;
|
||||||
|
border-top: var(--border);
|
||||||
|
padding-bottom: calc(var(--space-2) + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
body.edit-mode .cm-content {
|
||||||
|
padding-bottom: calc(5rem + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
.modal-backdrop { padding: var(--space-2); align-items: flex-start; }
|
.modal-backdrop { padding: var(--space-2); align-items: flex-start; }
|
||||||
.modal { max-width: none; margin-top: var(--space-4); }
|
.modal { max-width: none; margin-top: var(--space-4); }
|
||||||
.modal .panel-header { cursor: default; }
|
.modal .panel-header { cursor: default; }
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ func parseDiaryURLParts(fsPath string, depth int) (year, month, day string, ok b
|
|||||||
return "", "", "", false
|
return "", "", "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *diaryHandler) handle(root, fsPath, urlPath string) *specialPage {
|
func (d *diaryHandler) handle(root, fsPath, urlPath string, _ *http.Request) *specialPage {
|
||||||
depth, diaryRootFS, diaryRootURL, ok := findDiaryContext(root, fsPath, urlPath)
|
depth, diaryRootFS, diaryRootURL, ok := findDiaryContext(root, fsPath, urlPath)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
@@ -544,7 +544,6 @@ func buildMonthGrid(year, month int, today time.Time, currentDay int, hasDayEntr
|
|||||||
return weeks
|
return weeks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// diaryPhoto is a photo file whose name starts with a YYYY-MM-DD date prefix.
|
// diaryPhoto is a photo file whose name starts with a YYYY-MM-DD date prefix.
|
||||||
type diaryPhoto struct {
|
type diaryPhoto struct {
|
||||||
Date time.Time
|
Date time.Time
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Builds the vendored CodeMirror 6 bundle consumed by the page editor.
|
||||||
|
//
|
||||||
|
// Output is an IIFE that assigns the CM primitives the editor scripts need
|
||||||
|
// onto window.CM (see entry.js). The editor JS is loaded as plain global
|
||||||
|
// scripts, not ES modules, so there is no runtime module loader.
|
||||||
|
//
|
||||||
|
// Run via `make editor` (or `npm run build` here) after changing CM versions.
|
||||||
|
// The committed artifact at assets/editor/vendor/codemirror.bundle.js is the
|
||||||
|
// only thing `go build` ever sees.
|
||||||
|
import * as esbuild from "esbuild";
|
||||||
|
|
||||||
|
await esbuild.build({
|
||||||
|
entryPoints: ["entry.js"],
|
||||||
|
bundle: true,
|
||||||
|
format: "iife",
|
||||||
|
minify: true,
|
||||||
|
target: ["es2018"],
|
||||||
|
legalComments: "none",
|
||||||
|
outfile: "../assets/editor/vendor/codemirror.bundle.js",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("built assets/editor/vendor/codemirror.bundle.js");
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// CodeMirror 6 bundle entry point.
|
||||||
|
//
|
||||||
|
// Imports only the CM packages the editor needs (keep the bundle small for the
|
||||||
|
// mobile/VPN path) and exposes them on window.CM for the global-style editor
|
||||||
|
// scripts. The editor theme and markdown highlight palette draw from the app's
|
||||||
|
// :root CSS variables so there are no hardcoded colors/spacing here.
|
||||||
|
import { EditorState, EditorSelection, Compartment, Prec } from "@codemirror/state";
|
||||||
|
import { EditorView, keymap, drawSelection } from "@codemirror/view";
|
||||||
|
import { history, historyKeymap, defaultKeymap, indentWithTab, undo, redo, deleteLine } from "@codemirror/commands";
|
||||||
|
import { markdown, markdownLanguage, markdownKeymap } from "@codemirror/lang-markdown";
|
||||||
|
import { syntaxHighlighting, HighlightStyle, indentOnInput } from "@codemirror/language";
|
||||||
|
import {
|
||||||
|
autocompletion,
|
||||||
|
closeBrackets,
|
||||||
|
closeBracketsKeymap,
|
||||||
|
completionKeymap,
|
||||||
|
startCompletion,
|
||||||
|
} from "@codemirror/autocomplete";
|
||||||
|
import { tags } from "@lezer/highlight";
|
||||||
|
|
||||||
|
// Editor chrome. Colors/spacing/fonts come from :root variables; var() works
|
||||||
|
// here because HighlightStyle/theme emit real CSS rules.
|
||||||
|
const theme = EditorView.theme(
|
||||||
|
{
|
||||||
|
"&": {
|
||||||
|
backgroundColor: "var(--bg)",
|
||||||
|
color: "var(--text)",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
border: "var(--border)",
|
||||||
|
borderTop: "none",
|
||||||
|
},
|
||||||
|
"&.cm-focused": { outline: "none" },
|
||||||
|
".cm-scroller": {
|
||||||
|
fontFamily: '"Iosevka Slab", monospace',
|
||||||
|
lineHeight: "1.6",
|
||||||
|
minHeight: "60vh",
|
||||||
|
},
|
||||||
|
".cm-content": {
|
||||||
|
padding: "var(--space-4)",
|
||||||
|
caretColor: "var(--text)",
|
||||||
|
},
|
||||||
|
".cm-cursor, .cm-dropCursor": { borderLeftColor: "var(--text)" },
|
||||||
|
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":
|
||||||
|
{ backgroundColor: "var(--bg-panel-hover)" },
|
||||||
|
".cm-activeLine": { backgroundColor: "transparent" },
|
||||||
|
".cm-tooltip": {
|
||||||
|
backgroundColor: "var(--bg-panel)",
|
||||||
|
border: "var(--border-dashed)",
|
||||||
|
color: "var(--text)",
|
||||||
|
},
|
||||||
|
".cm-tooltip.cm-tooltip-autocomplete > ul": {
|
||||||
|
fontFamily: '"Iosevka Slab", monospace',
|
||||||
|
fontSize: "var(--font-sm)",
|
||||||
|
},
|
||||||
|
".cm-tooltip-autocomplete ul li[aria-selected]": {
|
||||||
|
backgroundColor: "var(--bg-panel-hover)",
|
||||||
|
color: "var(--text)",
|
||||||
|
},
|
||||||
|
".cm-completionDetail": {
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
fontStyle: "normal",
|
||||||
|
marginLeft: "var(--space-3)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ dark: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const highlightStyle = HighlightStyle.define([
|
||||||
|
{ tag: [tags.heading1, tags.heading2, tags.heading3, tags.heading4, tags.heading5, tags.heading6], color: "var(--secondary)", fontWeight: "bold" },
|
||||||
|
{ tag: tags.strong, fontWeight: "bold", color: "var(--text)" },
|
||||||
|
{ tag: tags.emphasis, fontStyle: "italic" },
|
||||||
|
{ tag: tags.strikethrough, textDecoration: "line-through" },
|
||||||
|
{ tag: [tags.link, tags.url], color: "var(--link)" },
|
||||||
|
{ tag: tags.monospace, color: "var(--primary-hover)" },
|
||||||
|
{ tag: tags.quote, color: "var(--text-muted)", fontStyle: "italic" },
|
||||||
|
{ tag: [tags.list, tags.contentSeparator], color: "var(--secondary)" },
|
||||||
|
{ tag: [tags.processingInstruction, tags.meta], color: "var(--text-muted)" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
window.CM = {
|
||||||
|
EditorState,
|
||||||
|
EditorSelection,
|
||||||
|
Compartment,
|
||||||
|
Prec,
|
||||||
|
EditorView,
|
||||||
|
keymap,
|
||||||
|
drawSelection,
|
||||||
|
history,
|
||||||
|
historyKeymap,
|
||||||
|
defaultKeymap,
|
||||||
|
indentWithTab,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
deleteLine,
|
||||||
|
markdown,
|
||||||
|
markdownLanguage,
|
||||||
|
markdownKeymap,
|
||||||
|
syntaxHighlighting,
|
||||||
|
indentOnInput,
|
||||||
|
autocompletion,
|
||||||
|
closeBrackets,
|
||||||
|
closeBracketsKeymap,
|
||||||
|
completionKeymap,
|
||||||
|
startCompletion,
|
||||||
|
theme,
|
||||||
|
highlightStyle,
|
||||||
|
};
|
||||||
Generated
+730
@@ -0,0 +1,730 @@
|
|||||||
|
{
|
||||||
|
"name": "datascape-editor-build",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "datascape-editor-build",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.18.0",
|
||||||
|
"@codemirror/commands": "^6.7.0",
|
||||||
|
"@codemirror/lang-markdown": "^6.3.0",
|
||||||
|
"@codemirror/language": "^6.10.0",
|
||||||
|
"@codemirror/state": "^6.4.0",
|
||||||
|
"@codemirror/view": "^6.34.0",
|
||||||
|
"@lezer/highlight": "^1.2.0",
|
||||||
|
"esbuild": "^0.24.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/autocomplete": {
|
||||||
|
"version": "6.20.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
|
||||||
|
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.17.0",
|
||||||
|
"@lezer/common": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/commands": {
|
||||||
|
"version": "6.10.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
|
||||||
|
"integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.6.0",
|
||||||
|
"@codemirror/view": "^6.27.0",
|
||||||
|
"@lezer/common": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/lang-css": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.0.0",
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@lezer/common": "^1.0.2",
|
||||||
|
"@lezer/css": "^1.1.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/lang-html": {
|
||||||
|
"version": "6.4.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
|
||||||
|
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.0.0",
|
||||||
|
"@codemirror/lang-css": "^6.0.0",
|
||||||
|
"@codemirror/lang-javascript": "^6.0.0",
|
||||||
|
"@codemirror/language": "^6.4.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.17.0",
|
||||||
|
"@lezer/common": "^1.0.0",
|
||||||
|
"@lezer/css": "^1.1.0",
|
||||||
|
"@lezer/html": "^1.3.12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/lang-javascript": {
|
||||||
|
"version": "6.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
|
||||||
|
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.0.0",
|
||||||
|
"@codemirror/language": "^6.6.0",
|
||||||
|
"@codemirror/lint": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.17.0",
|
||||||
|
"@lezer/common": "^1.0.0",
|
||||||
|
"@lezer/javascript": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/lang-markdown": {
|
||||||
|
"version": "6.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz",
|
||||||
|
"integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.7.1",
|
||||||
|
"@codemirror/lang-html": "^6.0.0",
|
||||||
|
"@codemirror/language": "^6.3.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.0.0",
|
||||||
|
"@lezer/common": "^1.2.1",
|
||||||
|
"@lezer/markdown": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/language": {
|
||||||
|
"version": "6.12.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
|
||||||
|
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.23.0",
|
||||||
|
"@lezer/common": "^1.5.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"@lezer/lr": "^1.0.0",
|
||||||
|
"style-mod": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/lint": {
|
||||||
|
"version": "6.9.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.6.tgz",
|
||||||
|
"integrity": "sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.42.0",
|
||||||
|
"crelt": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/state": {
|
||||||
|
"version": "6.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
|
||||||
|
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@marijn/find-cluster-break": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/view": {
|
||||||
|
"version": "6.43.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz",
|
||||||
|
"integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.6.0",
|
||||||
|
"crelt": "^1.0.6",
|
||||||
|
"style-mod": "^4.1.0",
|
||||||
|
"w3c-keyname": "^2.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/common": {
|
||||||
|
"version": "1.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
|
||||||
|
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/css": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.2.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"@lezer/lr": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/highlight": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/html": {
|
||||||
|
"version": "1.3.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
|
||||||
|
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.2.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"@lezer/lr": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/javascript": {
|
||||||
|
"version": "1.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
|
||||||
|
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.2.0",
|
||||||
|
"@lezer/highlight": "^1.1.3",
|
||||||
|
"@lezer/lr": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/lr": {
|
||||||
|
"version": "1.4.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
|
||||||
|
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/markdown": {
|
||||||
|
"version": "1.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.4.tgz",
|
||||||
|
"integrity": "sha512-N0SxazMj4k65DBfaf1azqtMZd6u7MqluP84/NZnB/io8Td9aleFmAhz9hcbvSfsxT5tdYlJ5qgv5aMJGY4zEtA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.5.0",
|
||||||
|
"@lezer/highlight": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@marijn/find-cluster-break": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/crelt": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.24.2",
|
||||||
|
"@esbuild/android-arm": "0.24.2",
|
||||||
|
"@esbuild/android-arm64": "0.24.2",
|
||||||
|
"@esbuild/android-x64": "0.24.2",
|
||||||
|
"@esbuild/darwin-arm64": "0.24.2",
|
||||||
|
"@esbuild/darwin-x64": "0.24.2",
|
||||||
|
"@esbuild/freebsd-arm64": "0.24.2",
|
||||||
|
"@esbuild/freebsd-x64": "0.24.2",
|
||||||
|
"@esbuild/linux-arm": "0.24.2",
|
||||||
|
"@esbuild/linux-arm64": "0.24.2",
|
||||||
|
"@esbuild/linux-ia32": "0.24.2",
|
||||||
|
"@esbuild/linux-loong64": "0.24.2",
|
||||||
|
"@esbuild/linux-mips64el": "0.24.2",
|
||||||
|
"@esbuild/linux-ppc64": "0.24.2",
|
||||||
|
"@esbuild/linux-riscv64": "0.24.2",
|
||||||
|
"@esbuild/linux-s390x": "0.24.2",
|
||||||
|
"@esbuild/linux-x64": "0.24.2",
|
||||||
|
"@esbuild/netbsd-arm64": "0.24.2",
|
||||||
|
"@esbuild/netbsd-x64": "0.24.2",
|
||||||
|
"@esbuild/openbsd-arm64": "0.24.2",
|
||||||
|
"@esbuild/openbsd-x64": "0.24.2",
|
||||||
|
"@esbuild/sunos-x64": "0.24.2",
|
||||||
|
"@esbuild/win32-arm64": "0.24.2",
|
||||||
|
"@esbuild/win32-ia32": "0.24.2",
|
||||||
|
"@esbuild/win32-x64": "0.24.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/style-mod": {
|
||||||
|
"version": "4.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
|
||||||
|
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/w3c-keyname": {
|
||||||
|
"version": "2.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "datascape-editor-build",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "One-time build tooling for the vendored CodeMirror 6 editor bundle. Dev-only: `go build` never runs Node, it only consumes the committed bundle artifact.",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "node build.mjs"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.18.0",
|
||||||
|
"@codemirror/commands": "^6.7.0",
|
||||||
|
"@codemirror/lang-markdown": "^6.3.0",
|
||||||
|
"@codemirror/language": "^6.10.0",
|
||||||
|
"@codemirror/state": "^6.4.0",
|
||||||
|
"@codemirror/view": "^6.34.0",
|
||||||
|
"@lezer/highlight": "^1.2.0",
|
||||||
|
"esbuild": "^0.24.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
+504
@@ -0,0 +1,504 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
pageTypeHandlers = append(pageTypeHandlers, &fitnessHandler{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// waistlineExportFile is the exact filename the user copies into the folder.
|
||||||
|
// The single file is always the latest export — no glob, no multi-file merge.
|
||||||
|
const waistlineExportFile = "waistline_export.json"
|
||||||
|
|
||||||
|
type fitnessHandler struct{}
|
||||||
|
|
||||||
|
// redirect: the fitness dashboard has no virtual URLs; everything renders
|
||||||
|
// inside the normal GET /{path}/ flow.
|
||||||
|
func (f *fitnessHandler) redirect(root, fsPath, urlPath string, r *http.Request) (string, bool) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle renders the dashboard for folders whose .page-settings declares
|
||||||
|
// type = fitness. Markdown content and the folder listing stay visible so
|
||||||
|
// the user can verify an uploaded export arrived.
|
||||||
|
func (f *fitnessHandler) handle(root, fsPath, urlPath string, r *http.Request) *specialPage {
|
||||||
|
s := readPageSettings(fsPath)
|
||||||
|
if s == nil || s.Type != "fitness" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
weightSel := validFitnessRange(r.URL.Query().Get("weight"), "3m")
|
||||||
|
weeklySel := validFitnessRange(r.URL.Query().Get("weekly"), "1y")
|
||||||
|
return &specialPage{
|
||||||
|
Content: renderFitnessDashboard(fsPath, weightSel, weeklySel),
|
||||||
|
SuppressTOC: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Time ranges ===
|
||||||
|
|
||||||
|
type fitnessRange struct {
|
||||||
|
Value string
|
||||||
|
Label string
|
||||||
|
Months int // 0 = all data
|
||||||
|
}
|
||||||
|
|
||||||
|
var fitnessRanges = []fitnessRange{
|
||||||
|
{"1m", "1 month", 1},
|
||||||
|
{"3m", "3 months", 3},
|
||||||
|
{"1y", "1 year", 12},
|
||||||
|
{"all", "All", 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
func validFitnessRange(v, fallback string) string {
|
||||||
|
for _, r := range fitnessRanges {
|
||||||
|
if r.Value == v {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func rangeMonths(v string) int {
|
||||||
|
for _, r := range fitnessRanges {
|
||||||
|
if r.Value == v {
|
||||||
|
return r.Months
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Waistline export parsing ===
|
||||||
|
|
||||||
|
// wlNum is a number in a Waistline export: values appear as JSON numbers,
|
||||||
|
// numeric strings, null, or are absent. Unparsable values read as not-ok
|
||||||
|
// instead of failing the whole export parse.
|
||||||
|
type wlNum struct {
|
||||||
|
val float64
|
||||||
|
ok bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *wlNum) UnmarshalJSON(b []byte) error {
|
||||||
|
s := strings.Trim(string(b), `"`)
|
||||||
|
if s == "" || s == "null" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseFloat(s, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n.val = v
|
||||||
|
n.ok = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calorie tracking was removed pending a rethink of the (undocumented)
|
||||||
|
// per-item formula; only the weight series is read from the export.
|
||||||
|
type wlExport struct {
|
||||||
|
Diary []wlDiaryEntry `json:"diary"`
|
||||||
|
Settings json.RawMessage `json:"settings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wlDiaryEntry struct {
|
||||||
|
DateTime string `json:"dateTime"`
|
||||||
|
Stats struct {
|
||||||
|
Weight wlNum `json:"weight"`
|
||||||
|
} `json:"stats"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// exportDate extracts the calendar day from a Waistline dateTime. The values
|
||||||
|
// are UTC-midnight timestamps; taking the first 10 characters avoids time
|
||||||
|
// zone conversions shifting the day.
|
||||||
|
func exportDate(s string) (time.Time, bool) {
|
||||||
|
if len(s) < 10 {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
t, err := time.Parse("2006-01-02", s[:10])
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// goalValue extracts settings.goals.<key>.goal-list[0].goal[0] — the first
|
||||||
|
// weekday slot of the shared goal. Best-effort: any missing or unparsable
|
||||||
|
// level means "no goal line", never an error.
|
||||||
|
func goalValue(settings json.RawMessage, key string) (float64, bool) {
|
||||||
|
var s struct {
|
||||||
|
Goals map[string]json.RawMessage `json:"goals"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(settings, &s) != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
var g struct {
|
||||||
|
GoalList []struct {
|
||||||
|
Goal []wlNum `json:"goal"`
|
||||||
|
} `json:"goal-list"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(s.Goals[key], &g) != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if len(g.GoalList) == 0 || len(g.GoalList[0].Goal) == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
n := g.GoalList[0].Goal[0]
|
||||||
|
return n.val, n.ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportWeightUnit(settings json.RawMessage) string {
|
||||||
|
var s struct {
|
||||||
|
Units struct {
|
||||||
|
Weight string `json:"weight"`
|
||||||
|
} `json:"units"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(settings, &s) == nil && s.Units.Weight != "" {
|
||||||
|
return s.Units.Weight
|
||||||
|
}
|
||||||
|
return "kg"
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Series extraction ===
|
||||||
|
|
||||||
|
type weightPoint struct {
|
||||||
|
date time.Time
|
||||||
|
value float64
|
||||||
|
days int // >0: weekly mean over this many measured days
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractWeights computes the weight series from the export: one point per
|
||||||
|
// diary entry with stats.weight. Duplicate dates (shouldn't happen) — last
|
||||||
|
// one wins.
|
||||||
|
func extractWeights(ex *wlExport) []weightPoint {
|
||||||
|
byDate := map[time.Time]float64{}
|
||||||
|
for _, e := range ex.Diary {
|
||||||
|
day, ok := exportDate(e.DateTime)
|
||||||
|
if !ok || !e.Stats.Weight.ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byDate[day] = e.Stats.Weight.val
|
||||||
|
}
|
||||||
|
weights := make([]weightPoint, 0, len(byDate))
|
||||||
|
for d, v := range byDate {
|
||||||
|
weights = append(weights, weightPoint{date: d, value: v})
|
||||||
|
}
|
||||||
|
sort.Slice(weights, func(i, j int) bool { return weights[i].date.Before(weights[j].date) })
|
||||||
|
return weights
|
||||||
|
}
|
||||||
|
|
||||||
|
func mondayOf(t time.Time) time.Time {
|
||||||
|
return t.AddDate(0, 0, -((int(t.Weekday()) + 6) % 7))
|
||||||
|
}
|
||||||
|
|
||||||
|
// weeklyMeanWeights buckets the daily weight series into ISO weeks (Monday
|
||||||
|
// start) and averages over the days that have a measurement. Weeks without
|
||||||
|
// any measurement produce no point.
|
||||||
|
func weeklyMeanWeights(points []weightPoint) []weightPoint {
|
||||||
|
type acc struct {
|
||||||
|
sum float64
|
||||||
|
n int
|
||||||
|
}
|
||||||
|
byWeek := map[time.Time]*acc{}
|
||||||
|
for _, p := range points {
|
||||||
|
w := mondayOf(p.date)
|
||||||
|
a := byWeek[w]
|
||||||
|
if a == nil {
|
||||||
|
a = &acc{}
|
||||||
|
byWeek[w] = a
|
||||||
|
}
|
||||||
|
a.sum += p.value
|
||||||
|
a.n++
|
||||||
|
}
|
||||||
|
out := make([]weightPoint, 0, len(byWeek))
|
||||||
|
for w, a := range byWeek {
|
||||||
|
out = append(out, weightPoint{date: w, value: a.sum / float64(a.n), days: a.n})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].date.Before(out[j].date) })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// === SVG geometry ===
|
||||||
|
|
||||||
|
// Chart canvas in viewBox units. The SVG scales to container width via
|
||||||
|
// viewBox + width:100%, so these only set proportions and text size.
|
||||||
|
const (
|
||||||
|
chartW = 560.0
|
||||||
|
chartH = 240.0
|
||||||
|
chartLeft = 46.0
|
||||||
|
chartRight = 8.0
|
||||||
|
chartTop = 10.0
|
||||||
|
chartBottom = 24.0
|
||||||
|
)
|
||||||
|
|
||||||
|
// svgNum formats a coordinate or display value: rounded to 2 decimals,
|
||||||
|
// trailing zeros trimmed.
|
||||||
|
func svgNum(v float64) string {
|
||||||
|
return strconv.FormatFloat(math.Round(v*100)/100, 'f', -1, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
type chartScale struct {
|
||||||
|
x0, x1 time.Time
|
||||||
|
y0, y1 float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s chartScale) x(t time.Time) float64 {
|
||||||
|
span := s.x1.Sub(s.x0).Seconds()
|
||||||
|
if span <= 0 {
|
||||||
|
return chartLeft
|
||||||
|
}
|
||||||
|
return chartLeft + (chartW-chartLeft-chartRight)*t.Sub(s.x0).Seconds()/span
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s chartScale) y(v float64) float64 {
|
||||||
|
span := s.y1 - s.y0
|
||||||
|
if span <= 0 {
|
||||||
|
return chartH - chartBottom
|
||||||
|
}
|
||||||
|
return chartH - chartBottom - (chartH-chartBottom-chartTop)*(v-s.y0)/span
|
||||||
|
}
|
||||||
|
|
||||||
|
// === View models ===
|
||||||
|
|
||||||
|
type fitnessOptVM struct {
|
||||||
|
Value, Label string
|
||||||
|
Selected bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type fitnessTickVM struct {
|
||||||
|
Pos, Label, Anchor string
|
||||||
|
}
|
||||||
|
|
||||||
|
type fitnessDotVM struct {
|
||||||
|
X, Y, Title string
|
||||||
|
}
|
||||||
|
|
||||||
|
type fitnessGoalVM struct {
|
||||||
|
Y, LabelY, Label string
|
||||||
|
}
|
||||||
|
|
||||||
|
type fitnessChartVM struct {
|
||||||
|
Title string
|
||||||
|
Param string
|
||||||
|
Options []fitnessOptVM
|
||||||
|
Empty bool
|
||||||
|
|
||||||
|
ViewW, ViewH string
|
||||||
|
PlotX, PlotY, PlotR, PlotB string
|
||||||
|
YLabelX, XLabelY string
|
||||||
|
YTicks, XTicks []fitnessTickVM
|
||||||
|
Goal *fitnessGoalVM
|
||||||
|
Lines []string // polyline points attributes
|
||||||
|
Dots []fitnessDotVM
|
||||||
|
}
|
||||||
|
|
||||||
|
type fitnessDashVM struct {
|
||||||
|
Notice string
|
||||||
|
Charts []fitnessChartVM
|
||||||
|
}
|
||||||
|
|
||||||
|
func newChartVM(title, param, sel string) fitnessChartVM {
|
||||||
|
opts := make([]fitnessOptVM, len(fitnessRanges))
|
||||||
|
for i, r := range fitnessRanges {
|
||||||
|
opts[i] = fitnessOptVM{r.Value, r.Label, r.Value == sel}
|
||||||
|
}
|
||||||
|
return fitnessChartVM{
|
||||||
|
Title: title, Param: param, Options: opts,
|
||||||
|
ViewW: svgNum(chartW), ViewH: svgNum(chartH),
|
||||||
|
PlotX: svgNum(chartLeft), PlotY: svgNum(chartTop),
|
||||||
|
PlotR: svgNum(chartW - chartRight), PlotB: svgNum(chartH - chartBottom),
|
||||||
|
YLabelX: svgNum(chartLeft - 5), XLabelY: svgNum(chartH - chartBottom + 14),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// yTickVMs places gridlines at fixed multiples of step across the y domain
|
||||||
|
// (always 5 kg guides on weight charts, regardless of range).
|
||||||
|
func yTickVMs(sc chartScale, step float64) []fitnessTickVM {
|
||||||
|
var out []fitnessTickVM
|
||||||
|
for v := math.Ceil(sc.y0/step) * step; v <= sc.y1+step/1e6; v += step {
|
||||||
|
out = append(out, fitnessTickVM{Pos: svgNum(sc.y(v)), Label: svgNum(v)})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vm *fitnessChartVM) setGoal(sc chartScale, goal float64, label string) {
|
||||||
|
gy := sc.y(goal)
|
||||||
|
ly := gy - 4
|
||||||
|
if ly < chartTop+10 {
|
||||||
|
ly = gy + 12
|
||||||
|
}
|
||||||
|
vm.Goal = &fitnessGoalVM{Y: svgNum(gy), LabelY: svgNum(ly), Label: label}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Chart builders ===
|
||||||
|
|
||||||
|
// buildWeightChart renders a weight line chart. It serves both the per-day
|
||||||
|
// series and the weekly-mean series (points carrying days > 0); the line is
|
||||||
|
// drawn continuous across days/weeks without a measurement.
|
||||||
|
func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, param, title, unit string, today time.Time) fitnessChartVM {
|
||||||
|
vm := newChartVM(title, param, sel)
|
||||||
|
|
||||||
|
points := all
|
||||||
|
var x0, x1 time.Time
|
||||||
|
if m := rangeMonths(sel); m > 0 {
|
||||||
|
x0 = today.AddDate(0, -m, 0)
|
||||||
|
points = filterWeights(all, x0)
|
||||||
|
if len(points) == 0 {
|
||||||
|
vm.Empty = true
|
||||||
|
return vm
|
||||||
|
}
|
||||||
|
x1 = today
|
||||||
|
if last := points[len(points)-1].date; last.After(x1) {
|
||||||
|
x1 = last
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if len(points) == 0 {
|
||||||
|
vm.Empty = true
|
||||||
|
return vm
|
||||||
|
}
|
||||||
|
x0 = points[0].date
|
||||||
|
x1 = points[len(points)-1].date
|
||||||
|
}
|
||||||
|
// Degenerate domain (single point on All): widen so the dot sits inside
|
||||||
|
// the plot instead of on its edge.
|
||||||
|
if !x0.Before(x1) {
|
||||||
|
x0 = x0.AddDate(0, 0, -1)
|
||||||
|
x1 = x1.AddDate(0, 0, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
lo, hi := points[0].value, points[0].value
|
||||||
|
for _, p := range points {
|
||||||
|
lo = min(lo, p.value)
|
||||||
|
hi = max(hi, p.value)
|
||||||
|
}
|
||||||
|
if hasGoal {
|
||||||
|
lo = min(lo, goal)
|
||||||
|
hi = max(hi, goal)
|
||||||
|
}
|
||||||
|
pad := (hi - lo) * 0.05
|
||||||
|
if pad == 0 {
|
||||||
|
pad = 1
|
||||||
|
}
|
||||||
|
sc := chartScale{x0, x1, lo - pad, hi + pad}
|
||||||
|
|
||||||
|
vm.YTicks = yTickVMs(sc, 5)
|
||||||
|
vm.XTicks = timeXTicks(sc, 4)
|
||||||
|
|
||||||
|
// One continuous polyline through every point in range — days without a
|
||||||
|
// measurement do not break the line. Point markers carry the hover
|
||||||
|
// <title>; on dense ranges they are dropped and the bare line stays
|
||||||
|
// legible. A single point in range renders as a dot.
|
||||||
|
dot := func(p weightPoint) fitnessDotVM {
|
||||||
|
label := p.date.Format("2006-01-02") + ": " + svgNum(p.value) + " " + unit
|
||||||
|
if p.days > 0 {
|
||||||
|
label = fmt.Sprintf("Week of %s: %s %s (%d days)",
|
||||||
|
p.date.Format("2006-01-02"), svgNum(p.value), unit, p.days)
|
||||||
|
}
|
||||||
|
return fitnessDotVM{X: svgNum(sc.x(p.date)), Y: svgNum(sc.y(p.value)), Title: label}
|
||||||
|
}
|
||||||
|
if len(points) >= 2 {
|
||||||
|
var b strings.Builder
|
||||||
|
for i, p := range points {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteByte(' ')
|
||||||
|
}
|
||||||
|
b.WriteString(svgNum(sc.x(p.date)))
|
||||||
|
b.WriteByte(',')
|
||||||
|
b.WriteString(svgNum(sc.y(p.value)))
|
||||||
|
}
|
||||||
|
vm.Lines = append(vm.Lines, b.String())
|
||||||
|
}
|
||||||
|
if len(points) <= 100 {
|
||||||
|
for _, p := range points {
|
||||||
|
vm.Dots = append(vm.Dots, dot(p))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasGoal {
|
||||||
|
vm.setGoal(sc, goal, "goal "+svgNum(goal))
|
||||||
|
}
|
||||||
|
return vm
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterWeights(points []weightPoint, from time.Time) []weightPoint {
|
||||||
|
var out []weightPoint
|
||||||
|
for _, p := range points {
|
||||||
|
if !p.date.Before(from) {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// timeXTicks places n+1 evenly spaced date labels across a continuous time
|
||||||
|
// axis; the last label is end-anchored so it stays inside the viewBox.
|
||||||
|
func timeXTicks(sc chartScale, n int) []fitnessTickVM {
|
||||||
|
span := sc.x1.Sub(sc.x0)
|
||||||
|
var out []fitnessTickVM
|
||||||
|
prev := ""
|
||||||
|
for i := 0; i <= n; i++ {
|
||||||
|
t := sc.x0.Add(time.Duration(float64(span) * float64(i) / float64(n)))
|
||||||
|
label := t.Format("2006-01-02")
|
||||||
|
if label == prev {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prev = label
|
||||||
|
anchor := "middle"
|
||||||
|
if i == n {
|
||||||
|
anchor = "end"
|
||||||
|
}
|
||||||
|
out = append(out, fitnessTickVM{Pos: svgNum(sc.x(t)), Label: label, Anchor: anchor})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Rendering ===
|
||||||
|
|
||||||
|
var fitnessTmpl = template.Must(template.ParseFS(assets, "assets/fitness/main.html"))
|
||||||
|
|
||||||
|
func renderFitnessDashboard(fsPath, weightSel, weeklySel string) template.HTML {
|
||||||
|
data := buildFitnessDash(fsPath, weightSel, weeklySel, time.Now())
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := fitnessTmpl.Execute(&buf, data); err != nil {
|
||||||
|
log.Printf("fitness template: %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return template.HTML(buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildFitnessDash reads and parses the export per request — no caching, no
|
||||||
|
// indexes. A read or parse failure (including a truncated mid-upload file)
|
||||||
|
// becomes an inline notice; the page itself always renders.
|
||||||
|
func buildFitnessDash(fsPath, weightSel, weeklySel string, now time.Time) fitnessDashVM {
|
||||||
|
raw, err := os.ReadFile(filepath.Join(fsPath, waistlineExportFile))
|
||||||
|
if err != nil {
|
||||||
|
return fitnessDashVM{Notice: "No Waistline export found — upload " + waistlineExportFile + " to this folder."}
|
||||||
|
}
|
||||||
|
var ex wlExport
|
||||||
|
if err := json.Unmarshal(raw, &ex); err != nil {
|
||||||
|
return fitnessDashVM{Notice: "Could not read " + waistlineExportFile + ": " + err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
weights := extractWeights(&ex)
|
||||||
|
wGoal, hasWGoal := goalValue(ex.Settings, "weight")
|
||||||
|
unit := exportWeightUnit(ex.Settings)
|
||||||
|
|
||||||
|
return fitnessDashVM{Charts: []fitnessChartVM{
|
||||||
|
buildWeightChart(weights, wGoal, hasWGoal, weightSel,
|
||||||
|
"weight", "Weight ("+unit+")", unit, today),
|
||||||
|
buildWeightChart(weeklyMeanWeights(weights), wGoal, hasWGoal, weeklySel,
|
||||||
|
"weekly", "Weekly average weight ("+unit+")", unit, today),
|
||||||
|
}}
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
"embed"
|
"embed"
|
||||||
|
"encoding/hex"
|
||||||
"flag"
|
"flag"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
@@ -19,9 +21,26 @@ import (
|
|||||||
//go:embed assets
|
//go:embed assets
|
||||||
var assets embed.FS
|
var assets embed.FS
|
||||||
|
|
||||||
|
// editorBundleVersion is a short content hash of the vendored CodeMirror bundle,
|
||||||
|
// appended as ?v=… to its <script> src. The bundle is served immutable under a
|
||||||
|
// stable filename, so without this query a rebuilt bundle would never reach a
|
||||||
|
// client that already cached the old one (this is the editor cache-bust knob).
|
||||||
|
var editorBundleVersion = hashAsset("assets/editor/vendor/codemirror.bundle.js")
|
||||||
|
|
||||||
|
func hashAsset(name string) string {
|
||||||
|
b, err := assets.ReadFile(name)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(b)
|
||||||
|
return hex.EncodeToString(sum[:])[:12]
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
pageTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/page/main.html"))
|
pageTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/page/main.html"))
|
||||||
editTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/editor/main.html"))
|
editTmpl = template.Must(template.New("edit").Funcs(template.FuncMap{
|
||||||
|
"editorBundleVersion": func() string { return editorBundleVersion },
|
||||||
|
}).ParseFS(assets, "assets/layout.html", "assets/editor/main.html"))
|
||||||
searchTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/search/main.html"))
|
searchTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/search/main.html"))
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,7 +58,9 @@ type specialPage struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// pageTypeHandler is implemented by each special folder type (diary, gallery, …).
|
// pageTypeHandler is implemented by each special folder type (diary, gallery, …).
|
||||||
// handle returns nil when the handler does not apply to the given path.
|
// handle returns nil when the handler does not apply to the given path. The
|
||||||
|
// request is passed read-only (e.g. query params selecting a view variant);
|
||||||
|
// mutations belong in the POST flow, not here.
|
||||||
// redirect returns ok=true with an absolute URL when the request should be
|
// redirect returns ok=true with an absolute URL when the request should be
|
||||||
// short-circuited with a 302 redirect (e.g. persistent date links in a diary,
|
// short-circuited with a 302 redirect (e.g. persistent date links in a diary,
|
||||||
// or virtual diary URLs in edit mode that delegate to the year file's editor).
|
// or virtual diary URLs in edit mode that delegate to the year file's editor).
|
||||||
@@ -47,7 +68,7 @@ type specialPage struct {
|
|||||||
// When adding a new hook, prefer a sibling method here over folding logic
|
// When adding a new hook, prefer a sibling method here over folding logic
|
||||||
// into main.go or render.go.
|
// into main.go or render.go.
|
||||||
type pageTypeHandler interface {
|
type pageTypeHandler interface {
|
||||||
handle(root, fsPath, urlPath string) *specialPage
|
handle(root, fsPath, urlPath string, r *http.Request) *specialPage
|
||||||
redirect(root, fsPath, urlPath string, r *http.Request) (target string, ok bool)
|
redirect(root, fsPath, urlPath string, r *http.Request) (target string, ok bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +111,7 @@ func main() {
|
|||||||
staticFS, _ := fs.Sub(assets, "assets")
|
staticFS, _ := fs.Sub(assets, "assets")
|
||||||
static := http.StripPrefix("/_/", http.FileServer(http.FS(staticFS)))
|
static := http.StripPrefix("/_/", http.FileServer(http.FS(staticFS)))
|
||||||
http.Handle("/_/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
http.Handle("/_/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if strings.HasPrefix(r.URL.Path, "/_/fonts/") {
|
if strings.HasPrefix(r.URL.Path, "/_/fonts/") || strings.HasPrefix(r.URL.Path, "/_/editor/vendor/") {
|
||||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
}
|
}
|
||||||
static.ServeHTTP(w, r)
|
static.ServeHTTP(w, r)
|
||||||
@@ -245,7 +266,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
var special *specialPage
|
var special *specialPage
|
||||||
if !editMode {
|
if !editMode {
|
||||||
for _, ph := range pageTypeHandlers {
|
for _, ph := range pageTypeHandlers {
|
||||||
if special = ph.handle(h.root, fsPath, urlPath); special != nil {
|
if special = ph.handle(h.root, fsPath, urlPath, r); special != nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,9 +277,10 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
rendered = renderMarkdown(rawMD)
|
rendered = renderMarkdown(rawMD)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
view, sortKey, order := readPageSettings(fsPath).viewSettings()
|
||||||
var entries []entry
|
var entries []entry
|
||||||
if !editMode && (special == nil || !special.SuppressListing) {
|
if !editMode && (special == nil || !special.SuppressListing) {
|
||||||
entries = listEntries(fsPath, urlPath)
|
entries = listEntries(fsPath, urlPath, sortKey, order)
|
||||||
}
|
}
|
||||||
|
|
||||||
title := pageTitle(urlPath)
|
title := pageTitle(urlPath)
|
||||||
@@ -312,6 +334,9 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
RawContent: rawContent,
|
RawContent: rawContent,
|
||||||
Content: rendered,
|
Content: rendered,
|
||||||
Entries: entries,
|
Entries: entries,
|
||||||
|
View: view,
|
||||||
|
Sort: sortKey,
|
||||||
|
Order: order,
|
||||||
SpecialContent: specialContent,
|
SpecialContent: specialContent,
|
||||||
SidebarWidget: sidebarWidget,
|
SidebarWidget: sidebarWidget,
|
||||||
SuppressTOC: suppressTOC,
|
SuppressTOC: suppressTOC,
|
||||||
@@ -354,6 +379,10 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
|||||||
h.handleAddTask(w, r, urlPath, fsPath)
|
h.handleAddTask(w, r, urlPath, fsPath)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if query.Has("settings") {
|
||||||
|
h.handleSettings(w, r, urlPath, fsPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
http.Error(w, "bad request", http.StatusBadRequest)
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
@@ -447,7 +476,8 @@ func readPageSettings(dir string) *pageSettings {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
s := &pageSettings{}
|
// Defaults; overridden only by valid values present in the file.
|
||||||
|
s := &pageSettings{View: viewList, Sort: sortName, Order: orderAsc}
|
||||||
for _, line := range strings.Split(string(data), "\n") {
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
if line == "" || strings.HasPrefix(line, "#") {
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
@@ -457,9 +487,16 @@ func readPageSettings(dir string) *pageSettings {
|
|||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
value := strings.TrimSpace(parts[1])
|
||||||
switch strings.TrimSpace(parts[0]) {
|
switch strings.TrimSpace(parts[0]) {
|
||||||
case "type":
|
case "type":
|
||||||
s.Type = strings.TrimSpace(parts[1])
|
s.Type = value
|
||||||
|
case "view":
|
||||||
|
s.View = validateView(value)
|
||||||
|
case "sort":
|
||||||
|
s.Sort = validateSort(value)
|
||||||
|
case "order":
|
||||||
|
s.Order = validateOrder(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleSettings persists the listing view/sort/order to the folder's
|
||||||
|
// .page-settings file. Values are validated against the allowed sets (unknown
|
||||||
|
// values fall back to defaults). Triggered by POST /{path}?settings.
|
||||||
|
func (h *handler) handleSettings(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view := validateView(r.FormValue("view"))
|
||||||
|
sortKey := validateSort(r.FormValue("sort"))
|
||||||
|
order := validateOrder(r.FormValue("order"))
|
||||||
|
|
||||||
|
if err := writePageSettings(fsPath, view, sortKey, order); err != nil {
|
||||||
|
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, urlPath, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePageSettings performs a read-modify-write of <dir>/.page-settings,
|
||||||
|
// updating the view/sort/order lines while preserving every other line
|
||||||
|
// (other keys, comments, blank lines, ordering) verbatim. Missing keys are
|
||||||
|
// appended. The write is atomic (temp file + rename).
|
||||||
|
func writePageSettings(dir, view, sortKey, order string) error {
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p := filepath.Join(dir, ".page-settings")
|
||||||
|
existing, err := os.ReadFile(p)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updated := updateSettingsLines(existing, view, sortKey, order)
|
||||||
|
return writeFileAtomic(p, updated, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateSettingsLines rewrites the view/sort/order lines in existing while
|
||||||
|
// leaving all other lines untouched. Every occurrence of a known key is
|
||||||
|
// updated (so the reader's last-wins parse stays consistent); keys absent from
|
||||||
|
// the file are appended in a stable order. The result always ends in a newline.
|
||||||
|
func updateSettingsLines(existing []byte, view, sortKey, order string) []byte {
|
||||||
|
targets := map[string]string{"view": view, "sort": sortKey, "order": order}
|
||||||
|
appendOrder := []string{"view", "sort", "order"}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
if len(existing) > 0 {
|
||||||
|
s := string(existing)
|
||||||
|
s = strings.TrimSuffix(s, "\n")
|
||||||
|
lines = strings.Split(s, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
eq := strings.IndexByte(line, '=')
|
||||||
|
if eq < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(line[:eq])
|
||||||
|
if val, ok := targets[key]; ok {
|
||||||
|
lines[i] = key + " = " + val
|
||||||
|
seen[key] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, k := range appendOrder {
|
||||||
|
if !seen[k] {
|
||||||
|
lines = append(lines, k+" = "+targets[k])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := strings.Join(lines, "\n")
|
||||||
|
if out != "" {
|
||||||
|
out += "\n"
|
||||||
|
}
|
||||||
|
return []byte(out)
|
||||||
|
}
|
||||||
@@ -4,10 +4,12 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/yuin/goldmark"
|
"github.com/yuin/goldmark"
|
||||||
"github.com/yuin/goldmark/extension"
|
"github.com/yuin/goldmark/extension"
|
||||||
@@ -31,6 +33,13 @@ func initMarkdown(root string) {
|
|||||||
type entry struct {
|
type entry struct {
|
||||||
Icon template.HTML
|
Icon template.HTML
|
||||||
Name, URL, Meta string
|
Name, URL, Meta string
|
||||||
|
// ThumbURL is set for thumbnailable files; the thumbnail view renders an
|
||||||
|
// <img> when it is non-empty and falls back to Icon otherwise.
|
||||||
|
ThumbURL string
|
||||||
|
// modTime/size carry the raw sort keys; the template only reads the
|
||||||
|
// formatted Meta string.
|
||||||
|
modTime time.Time
|
||||||
|
size int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type pageData struct {
|
type pageData struct {
|
||||||
@@ -45,15 +54,68 @@ type pageData struct {
|
|||||||
RawContent string
|
RawContent string
|
||||||
Content template.HTML
|
Content template.HTML
|
||||||
Entries []entry
|
Entries []entry
|
||||||
|
View string // listing view style: "list" or "thumbnail"
|
||||||
|
Sort string // listing sort key: "name" / "modified" / "size"
|
||||||
|
Order string // listing sort order: "asc" / "desc"
|
||||||
SpecialContent template.HTML
|
SpecialContent template.HTML
|
||||||
SidebarWidget template.HTML
|
SidebarWidget template.HTML
|
||||||
SuppressTOC bool
|
SuppressTOC bool
|
||||||
RenderMS int64
|
RenderMS int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// pageSettings holds the parsed contents of a .page-settings file.
|
// Allowed values for the listing view settings. Unknown values in the file or
|
||||||
|
// a POST body fall back to the first (default) value of each set.
|
||||||
|
const (
|
||||||
|
viewList = "list"
|
||||||
|
viewThumbnail = "thumbnail"
|
||||||
|
|
||||||
|
sortName = "name"
|
||||||
|
sortModified = "modified"
|
||||||
|
sortSize = "size"
|
||||||
|
|
||||||
|
orderAsc = "asc"
|
||||||
|
orderDesc = "desc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pageSettings holds the parsed contents of a .page-settings file. View, Sort,
|
||||||
|
// and Order are always valid once parsed (defaults applied on read).
|
||||||
type pageSettings struct {
|
type pageSettings struct {
|
||||||
Type string
|
Type string
|
||||||
|
View string
|
||||||
|
Sort string
|
||||||
|
Order string
|
||||||
|
}
|
||||||
|
|
||||||
|
// viewSettings returns the listing view/sort/order, applying defaults when the
|
||||||
|
// receiver is nil (no .page-settings file).
|
||||||
|
func (s *pageSettings) viewSettings() (view, sortKey, order string) {
|
||||||
|
if s == nil {
|
||||||
|
return viewList, sortName, orderAsc
|
||||||
|
}
|
||||||
|
return s.View, s.Sort, s.Order
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateView(v string) string {
|
||||||
|
if v == viewThumbnail {
|
||||||
|
return viewThumbnail
|
||||||
|
}
|
||||||
|
return viewList
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSort(v string) string {
|
||||||
|
switch v {
|
||||||
|
case sortModified, sortSize:
|
||||||
|
return v
|
||||||
|
default:
|
||||||
|
return sortName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateOrder(v string) string {
|
||||||
|
if v == orderDesc {
|
||||||
|
return orderDesc
|
||||||
|
}
|
||||||
|
return orderAsc
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -117,7 +179,7 @@ func parentURL(urlPath string) string {
|
|||||||
return parent + "/"
|
return parent + "/"
|
||||||
}
|
}
|
||||||
|
|
||||||
func listEntries(fsPath, urlPath string) []entry {
|
func listEntries(fsPath, urlPath, sortKey, order string) []entry {
|
||||||
entries, err := os.ReadDir(fsPath)
|
entries, err := os.ReadDir(fsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -136,30 +198,36 @@ func listEntries(fsPath, urlPath string) []entry {
|
|||||||
entryURL := path.Join(urlPath, name)
|
entryURL := path.Join(urlPath, name)
|
||||||
if e.IsDir() {
|
if e.IsDir() {
|
||||||
folders = append(folders, entry{
|
folders = append(folders, entry{
|
||||||
Icon: iconFolder,
|
Icon: iconFolder,
|
||||||
Name: name,
|
Name: name,
|
||||||
URL: entryURL + "/",
|
URL: entryURL + "/",
|
||||||
Meta: info.ModTime().Format("2006-01-02"),
|
Meta: info.ModTime().Format("2006-01-02"),
|
||||||
|
modTime: info.ModTime(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
if name == "index.md" {
|
if name == "index.md" {
|
||||||
continue // rendered above, don't list it
|
continue // rendered above, don't list it
|
||||||
}
|
}
|
||||||
files = append(files, entry{
|
f := entry{
|
||||||
Icon: fileIcon(name),
|
Icon: fileIcon(name),
|
||||||
Name: name,
|
Name: name,
|
||||||
URL: entryURL,
|
URL: entryURL,
|
||||||
Meta: formatSize(info.Size()) + " · " + info.ModTime().Format("2006-01-02"),
|
Meta: formatSize(info.Size()) + " · " + info.ModTime().Format("2006-01-02"),
|
||||||
})
|
modTime: info.ModTime(),
|
||||||
|
size: info.Size(),
|
||||||
|
}
|
||||||
|
if hasThumbnail(name) {
|
||||||
|
f.ThumbURL = thumbURL(path.Join(urlPath, url.PathEscape(name)), 300)
|
||||||
|
}
|
||||||
|
files = append(files, f)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Slice(folders, func(i, j int) bool {
|
// Folders always sort by name regardless of the chosen key (they have no
|
||||||
return strings.ToLower(folders[i].Name) < strings.ToLower(folders[j].Name)
|
// meaningful byte size); files honor the chosen key. The chosen order
|
||||||
})
|
// applies to both groups.
|
||||||
sort.Slice(files, func(i, j int) bool {
|
sortEntries(folders, sortName, order)
|
||||||
return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name)
|
sortEntries(files, sortKey, order)
|
||||||
})
|
|
||||||
|
|
||||||
// `..` row mirrors the header Up button so the listing itself is
|
// `..` row mirrors the header Up button so the listing itself is
|
||||||
// navigable without reaching for the header on mobile. Prepended after
|
// navigable without reaching for the header on mobile. Prepended after
|
||||||
@@ -177,6 +245,42 @@ func listEntries(fsPath, urlPath string) []entry {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sortEntries sorts a single group (folders or files) in place by the given
|
||||||
|
// key, breaking ties on case-insensitive name, then reverses for descending
|
||||||
|
// order. The stable sort keeps the name tiebreak meaningful.
|
||||||
|
func sortEntries(group []entry, sortKey, order string) {
|
||||||
|
sort.SliceStable(group, func(i, j int) bool {
|
||||||
|
a, b := group[i], group[j]
|
||||||
|
cmp := 0
|
||||||
|
switch sortKey {
|
||||||
|
case sortModified:
|
||||||
|
if a.modTime.Before(b.modTime) {
|
||||||
|
cmp = -1
|
||||||
|
} else if a.modTime.After(b.modTime) {
|
||||||
|
cmp = 1
|
||||||
|
}
|
||||||
|
case sortSize:
|
||||||
|
if a.size < b.size {
|
||||||
|
cmp = -1
|
||||||
|
} else if a.size > b.size {
|
||||||
|
cmp = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cmp == 0 {
|
||||||
|
an, bn := strings.ToLower(a.Name), strings.ToLower(b.Name)
|
||||||
|
if an < bn {
|
||||||
|
cmp = -1
|
||||||
|
} else if an > bn {
|
||||||
|
cmp = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if order == orderDesc {
|
||||||
|
return cmp > 0
|
||||||
|
}
|
||||||
|
return cmp < 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func readIcon(name string) template.HTML {
|
func readIcon(name string) template.HTML {
|
||||||
b, _ := assets.ReadFile("assets/icons/" + name + ".svg")
|
b, _ := assets.ReadFile("assets/icons/" + name + ".svg")
|
||||||
return template.HTML(strings.TrimSpace(string(b)))
|
return template.HTML(strings.TrimSpace(string(b)))
|
||||||
|
|||||||
+5
-4
@@ -115,13 +115,14 @@ func wikiTargetHref(target string) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// wikiTargetExists reports whether the on-disk folder backing the target
|
// wikiTargetExists reports whether the on-disk path backing the target exists
|
||||||
// exists under root.
|
// under root. Any existing path — file or folder — counts as resolved; only a
|
||||||
|
// missing path is treated as broken.
|
||||||
func wikiTargetExists(root, target string) bool {
|
func wikiTargetExists(root, target string) bool {
|
||||||
target = normalizeWikiTarget(target)
|
target = normalizeWikiTarget(target)
|
||||||
fsPath := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(target, "/")))
|
fsPath := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(target, "/")))
|
||||||
info, err := os.Stat(fsPath)
|
_, err := os.Stat(fsPath)
|
||||||
return err == nil && info.IsDir()
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// wikiDefaultDisplay returns the last segment of a target, or "/" for the root.
|
// wikiDefaultDisplay returns the last segment of a target, or "/" for the root.
|
||||||
|
|||||||
Reference in New Issue
Block a user