Compare commits
9 Commits
19017bf136
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| c85ea3bb0c | |||
| 8081c2ebd8 | |||
| 02fa19272d | |||
| b3ca714597 | |||
| c7d5db7af7 | |||
| ed8cb79b0b | |||
| c30c4d3a0d | |||
| ab22952e3d | |||
| 316551d263 |
1
.gitignore
vendored
@@ -3,3 +3,4 @@ wiki/
|
|||||||
|
|
||||||
# Binaries
|
# Binaries
|
||||||
datascape
|
datascape
|
||||||
|
*.exe
|
||||||
|
|||||||
87
AGENTS.md
@@ -1,87 +0,0 @@
|
|||||||
# AGENTS.md — Personal Wiki (go-wiki)
|
|
||||||
|
|
||||||
## Project Philosophy
|
|
||||||
This is a minimal personal wiki where the **folder structure is the wiki**.
|
|
||||||
There is no database, no CMS, no abstraction layer between the user and their files.
|
|
||||||
Every decision should reinforce this — if a proposed solution adds indirection between
|
|
||||||
the filesystem and what the user sees, question it.
|
|
||||||
|
|
||||||
## Core Concept
|
|
||||||
- Every folder *is* a page
|
|
||||||
- `index.md` in a folder is that page's content
|
|
||||||
- All related files (PDFs, images, CAD files, etc.) live in the same folder as `index.md`
|
|
||||||
- Image links in `index.md` like `` work because siblings are served at the same path
|
|
||||||
- There are no "attachments" — files are just files in a folder
|
|
||||||
|
|
||||||
## Target Environment
|
|
||||||
- Runs on a QNAP TS-431P3 NAS (Annapurna Labs AL-314, ARMv7 32-bit, `linux/arm`)
|
|
||||||
- All files live on the NAS and are mounted/accessed locally by the binary
|
|
||||||
- Users access via browser over Wireguard VPN from Windows, Linux, and Android
|
|
||||||
- Must cross-compile cleanly: `GOARCH=arm GOOS=linux GOARM=7 go build`
|
|
||||||
|
|
||||||
## Tech Constraints
|
|
||||||
- **Language:** Go
|
|
||||||
- **Output:** Single static binary, no installer, no runtime dependencies
|
|
||||||
- **Markdown:** `goldmark` for server-side rendering — no other markdown libraries
|
|
||||||
- **Assets:** Embedded via `embed.FS` — no external asset serving or CDN
|
|
||||||
- **HTTP:** stdlib `net/http` only — no web framework
|
|
||||||
- **Dependencies:** Keep to an absolute minimum. If stdlib can do it, use stdlib.
|
|
||||||
|
|
||||||
## HTTP Interface
|
|
||||||
The entire API surface should stay minimal:
|
|
||||||
|
|
||||||
| Method | Path | Behaviour |
|
|
||||||
|--------|------|-----------|
|
|
||||||
| GET | `/{path}` | If folder: render `index.md` + list contents. If file: serve raw. |
|
|
||||||
| GET | `/{path}?edit` | Mobile-friendly editor with `index.md` content in a textarea |
|
|
||||||
| POST | `/{path}` | Write updated `index.md` to disk |
|
|
||||||
|
|
||||||
Do not add endpoints beyond these without a concrete stated need.
|
|
||||||
|
|
||||||
## UI Principles
|
|
||||||
- Mobile-first — the primary editing device is Android over Wireguard
|
|
||||||
- No JavaScript frameworks — vanilla JS only, and only when necessary
|
|
||||||
- No build pipeline for frontend assets — what is embedded is what is served
|
|
||||||
- Readable on small screens without zooming
|
|
||||||
- Fast on a low-power ARM CPU — no heavy rendering, no large payloads
|
|
||||||
|
|
||||||
## Frontend Conventions
|
|
||||||
|
|
||||||
**JS file scoping:** each feature gets its own file. Global app behaviour goes in
|
|
||||||
`global-shortcuts.js`. Feature-specific logic gets its own file (e.g. `editor.js`).
|
|
||||||
Do not inline JS in the template or consolidate unrelated features into one file.
|
|
||||||
|
|
||||||
**Keyboard shortcuts:** `ALT+SHIFT` is the established modifier for all application
|
|
||||||
shortcuts — it avoids collisions with browser and OS bindings. Do not use other
|
|
||||||
modifiers for new shortcuts.
|
|
||||||
|
|
||||||
**Editor toolbar:** buttons use `data-action` (maps to a JS action function) and
|
|
||||||
`data-key` (the `ALT+SHIFT+KEY` shortcut letter). Adding a `data-key` to a button
|
|
||||||
automatically registers its shortcut — no extra wiring needed.
|
|
||||||
|
|
||||||
## Auth
|
|
||||||
- Basic auth is sufficient — this is a personal tool on a private VPN
|
|
||||||
- Do not over-engineer access control
|
|
||||||
|
|
||||||
## What to Avoid
|
|
||||||
- No database of any kind
|
|
||||||
- No indexing or caching layer unless explicitly requested and justified
|
|
||||||
- No parallel folder structures (the DokuWiki anti-pattern: `pages/` mirrored by `media/`)
|
|
||||||
- No frameworks (web, ORM, DI, etc.)
|
|
||||||
- No build steps for frontend assets
|
|
||||||
- Do not suggest Docker unless the user asks — a plain binary is preferred
|
|
||||||
|
|
||||||
## Development Order
|
|
||||||
When building new features, follow this priority order:
|
|
||||||
1. Correctness on the filesystem (never corrupt or lose user files)
|
|
||||||
2. Mobile usability
|
|
||||||
3. Simplicity of implementation
|
|
||||||
4. Performance
|
|
||||||
|
|
||||||
## Out of Scope (for now)
|
|
||||||
These are explicitly deferred — do not implement or scaffold unless asked:
|
|
||||||
- Full-text search
|
|
||||||
- File upload via browser
|
|
||||||
- Version history / git integration
|
|
||||||
- Multi-user support
|
|
||||||
- Tagging or metadata beyond `index.md` content
|
|
||||||
86
CLAUDE.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
`datascape` is a minimal personal wiki where **the folder structure is the wiki**.
|
||||||
|
No database, no CMS, no abstraction layer — every folder is a page, and `index.md`
|
||||||
|
in a folder is that page's content.
|
||||||
|
|
||||||
|
## Build & Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Local build (host architecture)
|
||||||
|
go build .
|
||||||
|
|
||||||
|
# Deploy to NAS
|
||||||
|
make deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## HTTP API Surface
|
||||||
|
|
||||||
|
| Method | Path | Behaviour |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| 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 |
|
||||||
|
| 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
|
||||||
|
are not redirected because `path.Clean` strips the trailing slash from `PostURL` and the
|
||||||
|
content would be lost).
|
||||||
|
|
||||||
|
Do not add new endpoints without a concrete stated need.
|
||||||
|
|
||||||
|
## Code Structure
|
||||||
|
|
||||||
|
When adding a new special folder type, create a new `.go` file. Do not add type-specific logic to `main.go` or `render.go`.
|
||||||
|
|
||||||
|
Prefer separate, human-readable `.html` files over inlined HTML strings in Go. Embed them via `embed.FS` if needed.
|
||||||
|
|
||||||
|
|
||||||
|
## Architecture Rules
|
||||||
|
|
||||||
|
- **Single binary** — no installer, no runtime dependencies, no Docker
|
||||||
|
- **Go stdlib `net/http`** only — no web framework
|
||||||
|
- **`goldmark`** for Markdown rendering — no other Markdown libraries
|
||||||
|
- **`embed.FS`** for all assets — no external serving, no CDN
|
||||||
|
- **No database** of any kind
|
||||||
|
- **No indexing or caching** unless explicitly requested and justified
|
||||||
|
- Keep dependencies to an absolute minimum; if stdlib can do it, use stdlib
|
||||||
|
|
||||||
|
## Frontend Rules
|
||||||
|
|
||||||
|
- Vanilla JS only — no frameworks, no build pipeline
|
||||||
|
- 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
|
||||||
|
- `ALT+SHIFT` is the modifier for all keyboard shortcuts — do not introduce others
|
||||||
|
- Editor toolbar buttons use `data-action` + `data-key`; adding `data-key` auto-registers the shortcut
|
||||||
|
- Prefer generic, descriptive CSS classes (`btn`, `btn-small`, `muted`, `danger`) over element-specific names (`save-button`, `cancel-button`, `form-name-input`). Use a modifier + base class pattern (`btn btn-small`) rather than one-off classes that duplicate shared styles.
|
||||||
|
- Where possible, re-use existing CSS classes
|
||||||
|
|
||||||
|
## Development Priorities
|
||||||
|
|
||||||
|
When building features, apply this order:
|
||||||
|
1. Correctness on the filesystem — never corrupt or lose files
|
||||||
|
2. Mobile usability (primary editing device is Android over Wireguard VPN)
|
||||||
|
3. Simplicity of implementation, adhere to KISS
|
||||||
|
4. Performance
|
||||||
|
|
||||||
|
## Date Formatting
|
||||||
|
|
||||||
|
- General UI dates (file listings, metadata): ISO `YYYY-MM-DD`
|
||||||
|
- Diary long-form dates: German locale, e.g. `Mittwoch, 1. April 2026` — use `formatGermanDate` in `diary.go`; Go's `time.Format` is English-only so locale names are kept in maps keyed by `time.Weekday` / `time.Month`
|
||||||
|
|
||||||
|
## What to Avoid
|
||||||
|
|
||||||
|
- Any parallel folder structure (e.g. a separate `media/` tree mirroring `pages/`)
|
||||||
|
- Over-engineering auth — Basic auth is sufficient for a personal VPN tool
|
||||||
|
- Heavy payloads or expensive rendering (target CPU: ARMv7 32-bit NAS)
|
||||||
|
- Suggesting Docker (plain binary is preferred)
|
||||||
|
|
||||||
|
## Out of Scope (do not implement unless explicitly asked)
|
||||||
|
|
||||||
|
- Full-text search
|
||||||
|
- Browser-based file upload
|
||||||
|
- Version history / git integration
|
||||||
|
- Multi-user support
|
||||||
|
- Tagging or metadata beyond `index.md` content
|
||||||
37
README.md
@@ -25,6 +25,39 @@ GOOS=linux GOARCH=arm go build -o datascape .
|
|||||||
|--------|-----|
|
|--------|-----|
|
||||||
| Browse | Navigate folders at `/` |
|
| Browse | Navigate folders at `/` |
|
||||||
| Read | Any folder with `index.md` renders it as HTML |
|
| Read | Any folder with `index.md` renders it as HTML |
|
||||||
| Edit | Append `?edit` to any folder URL, or click **Edit** |
|
| Edit | Append `?edit` to any folder URL, or click **[EDIT]** (Alt+Shift+E) |
|
||||||
| Save | POST from the edit form writes `index.md` to disk |
|
| Save | POST from the edit form writes `index.md` to disk; folder is created if needed |
|
||||||
|
| New page | Click **[NEW]** (Alt+Shift+N), enter a name — opens the new page in edit mode |
|
||||||
| Files | Drop PDFs, images, etc. next to `index.md` — they appear in the listing |
|
| Files | Drop PDFs, images, etc. next to `index.md` — they appear in the listing |
|
||||||
|
|
||||||
|
Navigating to a URL that does not exist shows an empty page with a **[CREATE]** prompt.
|
||||||
|
|
||||||
|
## Special Folder Types
|
||||||
|
|
||||||
|
A folder can opt into special rendering by adding a `.page-settings` file:
|
||||||
|
|
||||||
|
```
|
||||||
|
type = diary
|
||||||
|
```
|
||||||
|
|
||||||
|
### Diary
|
||||||
|
|
||||||
|
Designed for a chronological photo diary. Expected structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
FolderName/
|
||||||
|
.page-settings ← type = diary
|
||||||
|
YYYY/
|
||||||
|
YYYY-MM-DD Desc.jpg ← photos named with date prefix
|
||||||
|
MM/
|
||||||
|
DD/
|
||||||
|
index.md ← diary entry for that day
|
||||||
|
```
|
||||||
|
|
||||||
|
| View | What renders |
|
||||||
|
|------|-------------|
|
||||||
|
| Year (`YYYY/`) | Section per month with link and photo count |
|
||||||
|
| Month (`MM/`) | Section per day with entry content and photo grid |
|
||||||
|
| Day (`DD/`) | Entry content and photo grid |
|
||||||
|
|
||||||
|
Days with photos but no `index.md` still appear in the month view and can be created by clicking their heading link.
|
||||||
|
|||||||
7
assets/diary/diary-day.html
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{{if .Photos}}
|
||||||
|
<div class="diary-photo-grid">
|
||||||
|
{{range .Photos}}
|
||||||
|
<a href="{{.URL}}" target="_blank"><img src="{{.URL}}" alt="{{.Name}}" loading="lazy"></a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
16
assets/diary/diary-month.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{{range .Days}}
|
||||||
|
<div class="diary-section">
|
||||||
|
<h2>
|
||||||
|
{{if .URL}}<a href="{{.URL}}">{{.Heading}}</a>{{else}}{{.Heading}}{{end}}
|
||||||
|
{{if .EditURL}}<a href="{{.EditURL}}" class="btn btn-small">edit</a>{{end}}
|
||||||
|
</h2>
|
||||||
|
{{if .Content}}<div class="content">{{.Content}}</div>{{end}}
|
||||||
|
{{if .Photos}}
|
||||||
|
<div class="diary-photo-grid">
|
||||||
|
{{range .Photos}}
|
||||||
|
<a href="{{.URL}}" target="_blank"><img src="{{.URL}}" alt="{{.Name}}" loading="lazy"></a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
8
assets/diary/diary-year.html
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{{range .Months}}
|
||||||
|
<div class="diary-section">
|
||||||
|
<h2 class="diary-heading">
|
||||||
|
<a href="{{.URL}}">{{.Name}}</a>
|
||||||
|
{{if .PhotoCount}}<span class="diary-photo-count">({{.PhotoCount}} photos)</span>{{end}}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
166
assets/editor.js
@@ -2,6 +2,10 @@
|
|||||||
var textarea = document.getElementById('editor');
|
var textarea = document.getElementById('editor');
|
||||||
if (!textarea) return;
|
if (!textarea) return;
|
||||||
|
|
||||||
|
var form = textarea.closest('form');
|
||||||
|
|
||||||
|
// --- DOM helpers ---
|
||||||
|
|
||||||
function wrap(before, after, placeholder) {
|
function wrap(before, after, placeholder) {
|
||||||
var start = textarea.selectionStart;
|
var start = textarea.selectionStart;
|
||||||
var end = textarea.selectionEnd;
|
var end = textarea.selectionEnd;
|
||||||
@@ -26,24 +30,63 @@
|
|||||||
textarea.focus();
|
textarea.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
var form = textarea.closest('form');
|
function insertAtCursor(s) {
|
||||||
|
var start = textarea.selectionStart;
|
||||||
|
var end = textarea.selectionEnd;
|
||||||
|
textarea.value = textarea.value.slice(0, start) + s + textarea.value.slice(end);
|
||||||
|
textarea.selectionStart = textarea.selectionEnd = start + s.length;
|
||||||
|
textarea.dispatchEvent(new Event('input'));
|
||||||
|
textarea.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyResult(result) {
|
||||||
|
textarea.value = result.text;
|
||||||
|
textarea.selectionStart = textarea.selectionEnd = result.cursor;
|
||||||
|
textarea.dispatchEvent(new Event('input'));
|
||||||
|
textarea.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTableOp(fn, arg) {
|
||||||
|
var result = arg !== undefined
|
||||||
|
? fn(textarea.value, textarea.selectionStart, arg)
|
||||||
|
: fn(textarea.value, textarea.selectionStart);
|
||||||
|
if (result) applyResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Actions ---
|
||||||
|
|
||||||
|
var T = EditorTables;
|
||||||
|
var L = EditorLists;
|
||||||
|
var D = EditorDates;
|
||||||
|
|
||||||
var actions = {
|
var actions = {
|
||||||
save: function () { form.submit(); },
|
save: function () { form.submit(); },
|
||||||
bold: function () { wrap('**', '**', 'bold text'); },
|
bold: function () { wrap('**', '**', 'bold text'); },
|
||||||
italic: function () { wrap('*', '*', 'italic text'); },
|
italic: function () { wrap('*', '*', 'italic text'); },
|
||||||
h1: function () { linePrefix('# '); },
|
h1: function () { linePrefix('# '); },
|
||||||
h2: function () { linePrefix('## '); },
|
h2: function () { linePrefix('## '); },
|
||||||
h3: function () { linePrefix('### '); },
|
h3: function () { linePrefix('### '); },
|
||||||
code: function () { wrap('`', '`', 'code'); },
|
code: function () { wrap('`', '`', 'code'); },
|
||||||
codeblock: function () { wrap('```\n', '\n```', 'code'); },
|
codeblock: function () { wrap('```\n', '\n```', 'code'); },
|
||||||
quote: function () { linePrefix('> '); },
|
quote: function () { linePrefix('> '); },
|
||||||
link: function () { wrap('[', '](url)', 'link text'); },
|
link: function () { wrap('[', '](url)', 'link text'); },
|
||||||
ul: function () { linePrefix('- '); },
|
ul: function () { linePrefix('- '); },
|
||||||
ol: function () { linePrefix('1. '); },
|
ol: function () { linePrefix('1. '); },
|
||||||
hr: function () { wrap('\n\n---\n\n', '', ''); },
|
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()); },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Keyboard shortcut registration ---
|
||||||
|
|
||||||
var keyMap = {};
|
var keyMap = {};
|
||||||
document.querySelectorAll('[data-action]').forEach(function (btn) {
|
document.querySelectorAll('[data-action]').forEach(function (btn) {
|
||||||
btn.addEventListener('click', function () {
|
btn.addEventListener('click', function () {
|
||||||
@@ -55,6 +98,10 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
keyMap['T'] = actions.fmttable;
|
||||||
|
keyMap['D'] = actions.dateiso;
|
||||||
|
keyMap['W'] = actions.datelong;
|
||||||
|
|
||||||
document.addEventListener('keydown', function (e) {
|
document.addEventListener('keydown', function (e) {
|
||||||
if (!e.altKey || !e.shiftKey) return;
|
if (!e.altKey || !e.shiftKey) return;
|
||||||
var action = keyMap[e.key];
|
var action = keyMap[e.key];
|
||||||
@@ -63,4 +110,95 @@
|
|||||||
action();
|
action();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Dropdown helper ---
|
||||||
|
|
||||||
|
var openMenus = [];
|
||||||
|
|
||||||
|
function makeDropdown(triggerBtn, items) {
|
||||||
|
var menu = document.createElement('div');
|
||||||
|
menu.className = 'toolbar-dropdown-menu';
|
||||||
|
items.forEach(function (item) {
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'btn btn-tool toolbar-dropdown-item';
|
||||||
|
btn.textContent = item.label;
|
||||||
|
btn.addEventListener('mousedown', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
actions[item.action]();
|
||||||
|
menu.classList.remove('is-open');
|
||||||
|
});
|
||||||
|
menu.appendChild(btn);
|
||||||
|
});
|
||||||
|
triggerBtn.appendChild(menu);
|
||||||
|
openMenus.push(menu);
|
||||||
|
|
||||||
|
triggerBtn.addEventListener('click', function (e) {
|
||||||
|
if (e.target !== triggerBtn) return;
|
||||||
|
var wasOpen = menu.classList.contains('is-open');
|
||||||
|
openMenus.forEach(function (m) { m.classList.remove('is-open'); });
|
||||||
|
if (!wasOpen) menu.classList.add('is-open');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var insideAny = openMenus.some(function (m) {
|
||||||
|
return m.parentElement && m.parentElement.contains(e.target);
|
||||||
|
});
|
||||||
|
if (!insideAny) openMenus.forEach(function (m) { m.classList.remove('is-open'); });
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') openMenus.forEach(function (m) { m.classList.remove('is-open'); });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Table dropdown ---
|
||||||
|
|
||||||
|
var tblDropBtn = document.querySelector('[data-action="tbldrop"]');
|
||||||
|
if (tblDropBtn) {
|
||||||
|
makeDropdown(tblDropBtn, [
|
||||||
|
{ label: 'Format table', action: 'fmttable' },
|
||||||
|
{ label: 'Align left', action: 'tblalignleft' },
|
||||||
|
{ label: 'Align center', action: 'tblaligncenter' },
|
||||||
|
{ label: 'Align right', action: 'tblalignright' },
|
||||||
|
{ label: 'Insert column', action: 'tblinsertcol' },
|
||||||
|
{ label: 'Delete column', action: 'tbldeletecol' },
|
||||||
|
{ label: 'Insert row', action: 'tblinsertrow' },
|
||||||
|
{ label: 'Delete row', action: 'tbldeleterow' },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Date dropdown ---
|
||||||
|
|
||||||
|
var dateDropBtn = document.querySelector('[data-action="datedrop"]');
|
||||||
|
if (dateDropBtn) {
|
||||||
|
makeDropdown(dateDropBtn, [
|
||||||
|
{ label: 'YYYY-MM-DD', action: 'dateiso' },
|
||||||
|
{ label: 'DE Long', action: 'datelong' },
|
||||||
|
]);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
17
assets/editor/dates.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
window.EditorDates = (function () {
|
||||||
|
|
||||||
|
function pad2(n) { return n < 10 ? '0' + n : '' + n; }
|
||||||
|
|
||||||
|
function isoDate() {
|
||||||
|
var d = new Date();
|
||||||
|
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
function longDate() {
|
||||||
|
return new Date().toLocaleDateString('de-DE', {
|
||||||
|
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isoDate: isoDate, longDate: longDate };
|
||||||
|
})();
|
||||||
86
assets/editor/lists.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
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 };
|
||||||
|
})();
|
||||||
274
assets/editor/tables.js
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
window.EditorTables = (function () {
|
||||||
|
|
||||||
|
function repeat(ch, n) {
|
||||||
|
var s = '';
|
||||||
|
for (var i = 0; i < n; i++) s += ch;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function padLeft(s, w) {
|
||||||
|
while (s.length < w) s = ' ' + s;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function padRight(s, w) {
|
||||||
|
while (s.length < w) s += ' ';
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function padCenter(s, w) {
|
||||||
|
while (s.length < w) {
|
||||||
|
s = s + ' ';
|
||||||
|
if (s.length < w) s = ' ' + s;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTableRow(line) {
|
||||||
|
var trimmed = line.trim();
|
||||||
|
if (trimmed.charAt(0) === '|') trimmed = trimmed.slice(1);
|
||||||
|
if (trimmed.charAt(trimmed.length - 1) === '|') trimmed = trimmed.slice(0, -1);
|
||||||
|
return trimmed.split('|').map(function (c) { return c.trim(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSeparatorRow(cells) {
|
||||||
|
return cells.every(function (c) { return /^:?-+:?$/.test(c); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAlignment(cell) {
|
||||||
|
var left = cell.charAt(0) === ':';
|
||||||
|
var right = cell.charAt(cell.length - 1) === ':';
|
||||||
|
if (left && right) return 'center';
|
||||||
|
if (left) return 'left';
|
||||||
|
if (right) return 'right';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSepCell(width, align) {
|
||||||
|
if (align === 'left') return ':' + repeat('-', width - 1);
|
||||||
|
if (align === 'center') return ':' + repeat('-', width - 2) + ':';
|
||||||
|
if (align === 'right') return repeat('-', width - 1) + ':';
|
||||||
|
return repeat('-', width);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTableRange(text, cursorPos) {
|
||||||
|
var lines = text.split('\n');
|
||||||
|
var charCount = 0;
|
||||||
|
var cursorLine = 0;
|
||||||
|
for (var i = 0; i < lines.length; i++) {
|
||||||
|
if (charCount + lines[i].length >= cursorPos && charCount <= cursorPos) {
|
||||||
|
cursorLine = i;
|
||||||
|
}
|
||||||
|
charCount += lines[i].length + 1;
|
||||||
|
}
|
||||||
|
if (!/^\|.*\|$/.test(lines[cursorLine].trim())) return null;
|
||||||
|
var start = cursorLine;
|
||||||
|
while (start > 0 && /^\|.*\|$/.test(lines[start - 1].trim())) start--;
|
||||||
|
var end = cursorLine;
|
||||||
|
while (end < lines.length - 1 && /^\|.*\|$/.test(lines[end + 1].trim())) end++;
|
||||||
|
if (end - start < 1) return null;
|
||||||
|
return { start: start, end: end, lines: lines, cursorLine: cursorLine };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTableText(text, cursorPos) {
|
||||||
|
var range = findTableRange(text, cursorPos);
|
||||||
|
if (!range) return null;
|
||||||
|
|
||||||
|
var rows = [];
|
||||||
|
var sepIndex = -1;
|
||||||
|
var alignments = [];
|
||||||
|
for (var i = range.start; i <= range.end; i++) {
|
||||||
|
var cells = parseTableRow(range.lines[i]);
|
||||||
|
if (sepIndex === -1 && isSeparatorRow(cells)) {
|
||||||
|
sepIndex = rows.length;
|
||||||
|
alignments = cells.map(function (c) { return parseAlignment(c); });
|
||||||
|
}
|
||||||
|
rows.push(cells);
|
||||||
|
}
|
||||||
|
if (sepIndex === -1) return null;
|
||||||
|
|
||||||
|
var colCount = 0;
|
||||||
|
rows.forEach(function (r) { if (r.length > colCount) colCount = r.length; });
|
||||||
|
while (alignments.length < colCount) alignments.push(null);
|
||||||
|
|
||||||
|
rows = rows.map(function (r) {
|
||||||
|
while (r.length < colCount) r.push('');
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
|
||||||
|
var widths = [];
|
||||||
|
for (var c = 0; c < colCount; c++) {
|
||||||
|
var max = 3;
|
||||||
|
for (var r = 0; r < rows.length; r++) {
|
||||||
|
if (r === sepIndex) continue;
|
||||||
|
if (rows[r][c].length > max) max = rows[r][c].length;
|
||||||
|
}
|
||||||
|
widths.push(max);
|
||||||
|
}
|
||||||
|
|
||||||
|
var formatted = rows.map(function (row, ri) {
|
||||||
|
var cells = row.map(function (cell, ci) {
|
||||||
|
var w = widths[ci];
|
||||||
|
if (ri === sepIndex) return makeSepCell(w, alignments[ci]);
|
||||||
|
var align = alignments[ci];
|
||||||
|
if (align === 'right') return padLeft(cell, w);
|
||||||
|
if (align === 'center') return padCenter(cell, w);
|
||||||
|
return padRight(cell, w);
|
||||||
|
});
|
||||||
|
return '| ' + cells.join(' | ') + ' |';
|
||||||
|
});
|
||||||
|
|
||||||
|
var beforeTable = range.lines.slice(0, range.start).join('\n');
|
||||||
|
var afterTable = range.lines.slice(range.end + 1).join('\n');
|
||||||
|
var parts = [];
|
||||||
|
if (beforeTable) parts.push(beforeTable);
|
||||||
|
parts.push(formatted.join('\n'));
|
||||||
|
if (afterTable) parts.push(afterTable);
|
||||||
|
var newText = parts.join('\n');
|
||||||
|
|
||||||
|
var oldBeforeLen = 0;
|
||||||
|
for (var i = 0; i < range.start; i++) oldBeforeLen += range.lines[i].length + 1;
|
||||||
|
var cursorInTable = cursorPos - oldBeforeLen;
|
||||||
|
var newTableText = formatted.join('\n');
|
||||||
|
var newCursor = (beforeTable ? beforeTable.length + 1 : 0) + Math.min(cursorInTable, newTableText.length);
|
||||||
|
|
||||||
|
return { text: newText, cursor: newCursor };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCursorColumn(text, cursorPos) {
|
||||||
|
var range = findTableRange(text, cursorPos);
|
||||||
|
if (!range) return null;
|
||||||
|
var lines = text.split('\n');
|
||||||
|
var charCount = 0;
|
||||||
|
for (var i = 0; i < range.cursorLine; i++) charCount += lines[i].length + 1;
|
||||||
|
var lineOffset = cursorPos - charCount;
|
||||||
|
var line = lines[range.cursorLine];
|
||||||
|
var col = -1;
|
||||||
|
for (var c = 0; c < line.length; c++) {
|
||||||
|
if (line.charAt(c) === '|') {
|
||||||
|
col++;
|
||||||
|
if (c >= lineOffset) return Math.max(col - 1, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Math.max(col, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setColumnAlignment(text, cursorPos, align) {
|
||||||
|
var range = findTableRange(text, cursorPos);
|
||||||
|
if (!range) return null;
|
||||||
|
var colIdx = getCursorColumn(text, cursorPos);
|
||||||
|
if (colIdx === null) return null;
|
||||||
|
|
||||||
|
var rows = [];
|
||||||
|
var sepIndex = -1;
|
||||||
|
for (var i = range.start; i <= range.end; i++) {
|
||||||
|
var cells = parseTableRow(range.lines[i]);
|
||||||
|
if (sepIndex === -1 && isSeparatorRow(cells)) sepIndex = rows.length;
|
||||||
|
rows.push(cells);
|
||||||
|
}
|
||||||
|
if (sepIndex === -1 || colIdx >= rows[sepIndex].length) return null;
|
||||||
|
|
||||||
|
var cell = rows[sepIndex][colIdx].replace(/:/g, '-');
|
||||||
|
if (align === 'left') cell = ':' + cell.slice(1);
|
||||||
|
else if (align === 'center') cell = ':' + cell.slice(1, -1) + ':';
|
||||||
|
else if (align === 'right') cell = cell.slice(0, -1) + ':';
|
||||||
|
rows[sepIndex][colIdx] = cell;
|
||||||
|
|
||||||
|
var newLines = range.lines.slice();
|
||||||
|
for (var i = 0; i < rows.length; i++) {
|
||||||
|
newLines[range.start + i] = '| ' + rows[i].join(' | ') + ' |';
|
||||||
|
}
|
||||||
|
return formatTableText(newLines.join('\n'), cursorPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertColumn(text, cursorPos) {
|
||||||
|
var range = findTableRange(text, cursorPos);
|
||||||
|
if (!range) return null;
|
||||||
|
var colIdx = getCursorColumn(text, cursorPos);
|
||||||
|
if (colIdx === null) return null;
|
||||||
|
|
||||||
|
var newLines = range.lines.slice();
|
||||||
|
var sepIndex = -1;
|
||||||
|
for (var i = range.start; i <= range.end; i++) {
|
||||||
|
var cells = parseTableRow(range.lines[i]);
|
||||||
|
var isSep = sepIndex === -1 && isSeparatorRow(cells);
|
||||||
|
if (isSep) sepIndex = i;
|
||||||
|
cells.splice(colIdx, 0, isSep ? '---' : '');
|
||||||
|
newLines[i] = '| ' + cells.join(' | ') + ' |';
|
||||||
|
}
|
||||||
|
return formatTableText(newLines.join('\n'), cursorPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteColumn(text, cursorPos) {
|
||||||
|
var range = findTableRange(text, cursorPos);
|
||||||
|
if (!range) return null;
|
||||||
|
var colIdx = getCursorColumn(text, cursorPos);
|
||||||
|
if (colIdx === null) return null;
|
||||||
|
|
||||||
|
var newLines = range.lines.slice();
|
||||||
|
for (var i = range.start; i <= range.end; i++) {
|
||||||
|
var cells = parseTableRow(range.lines[i]);
|
||||||
|
if (cells.length <= 1) return null;
|
||||||
|
cells.splice(colIdx, 1);
|
||||||
|
newLines[i] = '| ' + cells.join(' | ') + ' |';
|
||||||
|
}
|
||||||
|
return formatTableText(newLines.join('\n'), cursorPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertRow(text, cursorPos) {
|
||||||
|
var range = findTableRange(text, cursorPos);
|
||||||
|
if (!range) return null;
|
||||||
|
var colCount = 0;
|
||||||
|
for (var i = range.start; i <= range.end; i++) {
|
||||||
|
var cells = parseTableRow(range.lines[i]);
|
||||||
|
if (cells.length > colCount) colCount = cells.length;
|
||||||
|
}
|
||||||
|
var emptyCells = [];
|
||||||
|
for (var c = 0; c < colCount; c++) emptyCells.push('');
|
||||||
|
var newLines = range.lines.slice();
|
||||||
|
newLines.splice(range.cursorLine + 1, 0, '| ' + emptyCells.join(' | ') + ' |');
|
||||||
|
return formatTableText(newLines.join('\n'), cursorPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertRowBelow(text, cursorPos) {
|
||||||
|
var range = findTableRange(text, cursorPos);
|
||||||
|
if (!range) return null;
|
||||||
|
var result = insertRow(text, cursorPos);
|
||||||
|
if (!result) return null;
|
||||||
|
var lines = result.text.split('\n');
|
||||||
|
var cursor = 0;
|
||||||
|
for (var i = 0; i <= range.cursorLine; i++) cursor += lines[i].length + 1;
|
||||||
|
cursor += 2; // skip leading '| '
|
||||||
|
return { text: result.text, cursor: cursor };
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteRow(text, cursorPos) {
|
||||||
|
var range = findTableRange(text, cursorPos);
|
||||||
|
if (!range) return null;
|
||||||
|
var sepIndex = -1;
|
||||||
|
for (var i = range.start; i <= range.end; i++) {
|
||||||
|
if (isSeparatorRow(parseTableRow(range.lines[i]))) { sepIndex = i; break; }
|
||||||
|
}
|
||||||
|
if (range.cursorLine === range.start || range.cursorLine === sepIndex) return null;
|
||||||
|
|
||||||
|
var newLines = range.lines.slice();
|
||||||
|
newLines.splice(range.cursorLine, 1);
|
||||||
|
var newCursor = cursorPos;
|
||||||
|
if (range.cursorLine < newLines.length) {
|
||||||
|
var charCount = 0;
|
||||||
|
for (var i = 0; i < range.cursorLine; i++) charCount += newLines[i].length + 1;
|
||||||
|
newCursor = charCount;
|
||||||
|
}
|
||||||
|
return formatTableText(newLines.join('\n'), Math.min(newCursor, newLines.join('\n').length));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
formatTableText: formatTableText,
|
||||||
|
setColumnAlignment: setColumnAlignment,
|
||||||
|
insertColumn: insertColumn,
|
||||||
|
deleteColumn: deleteColumn,
|
||||||
|
insertRow: insertRow,
|
||||||
|
insertRowBelow: insertRowBelow,
|
||||||
|
deleteRow: deleteRow,
|
||||||
|
};
|
||||||
|
})();
|
||||||
BIN
assets/fonts/IosevkaEtoile.woff2
Normal file
BIN
assets/fonts/IosevkaSlab.woff2
Normal file
7
assets/icons/archive.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges">
|
||||||
|
<rect x="1" y="1" width="14" height="4"/>
|
||||||
|
<path d="M1 5v10h14V5M7 3h2"/>
|
||||||
|
<rect x="7" y="6" width="2" height="2" fill="currentColor" stroke="none"/>
|
||||||
|
<rect x="7" y="9" width="2" height="1" fill="currentColor" stroke="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 435 B |
7
assets/icons/audio.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
||||||
|
fill="currentColor" stroke="none" shape-rendering="crispEdges">
|
||||||
|
<path d="M2 6h3l4-3v10l-4-3H2z"/>
|
||||||
|
<rect x="11" y="5" width="2" height="1"/>
|
||||||
|
<rect x="11" y="7" width="3" height="1"/>
|
||||||
|
<rect x="11" y="9" width="2" height="1"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 329 B |
4
assets/icons/doc.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges">
|
||||||
|
<path d="M3 1h7l4 4v10H3zM10 1v4h4M5 8h6M5 11h4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 257 B |
4
assets/icons/folder.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges">
|
||||||
|
<path d="M1 6h14v8H1zm0 0V4h5l1 2"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 243 B |
4
assets/icons/generic.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges">
|
||||||
|
<path d="M3 1h7l4 4v10H3zM10 1v4h4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 244 B |
6
assets/icons/image.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges">
|
||||||
|
<rect x="1" y="2" width="14" height="12"/>
|
||||||
|
<path d="M1 11l4-4 3 3 2-2 5 5"/>
|
||||||
|
<rect x="10" y="4" width="2" height="2" fill="currentColor" stroke="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 363 B |
5
assets/icons/video.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges">
|
||||||
|
<rect x="1" y="3" width="14" height="10"/>
|
||||||
|
<path d="M6 6v4l5-2z" fill="currentColor" stroke="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 309 B |
@@ -16,35 +16,42 @@
|
|||||||
><a href="{{.URL}}">{{.Name}}</a>{{end}}
|
><a href="{{.URL}}">{{.Name}}</a>{{end}}
|
||||||
</nav>
|
</nav>
|
||||||
{{if .EditMode}}
|
{{if .EditMode}}
|
||||||
<a class="btn-cancel" href="{{.PostURL}}">CANCEL</a>
|
<a class="btn" href="{{.PostURL}}">CANCEL</a>
|
||||||
<button class="btn-save" type="submit" form="edit-form" data-action="save" data-key="S" title="Save (S)">SAVE</button>
|
<button class="btn" type="submit" form="edit-form" data-action="save" data-key="S" title="Save (S)">SAVE</button>
|
||||||
{{else if .CanEdit}}
|
{{else if .CanEdit}}
|
||||||
<button class="new-btn" onclick="newPage()" title="New page (N)">NEW</button>
|
<button class="btn" onclick="newPage()" title="New page (N)">NEW</button>
|
||||||
<a class="edit-btn" href="?edit" title="Edit page (E)">EDIT</a>
|
<a class="btn" href="?edit" title="Edit page (E)">EDIT</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
{{if .EditMode}}
|
{{if .EditMode}}
|
||||||
<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}}
|
||||||
<div class="editor-toolbar">
|
<div class="editor-toolbar">
|
||||||
<button type="button" class="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-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="toolbar-sep"></span>
|
<span class="toolbar-sep"></span>
|
||||||
<button type="button" class="btn-tool" data-action="h1" data-key="1" title="Heading 1 (1)">#</button>
|
<button type="button" class="btn btn-tool" data-action="h1" data-key="1" title="Heading 1 (1)">#</button>
|
||||||
<button type="button" class="btn-tool" data-action="h2" data-key="2" title="Heading 2 (2)">##</button>
|
<button type="button" class="btn btn-tool" data-action="h2" data-key="2" title="Heading 2 (2)">##</button>
|
||||||
<button type="button" class="btn-tool" data-action="h3" data-key="3" title="Heading 3 (3)">###</button>
|
<button type="button" class="btn btn-tool" data-action="h3" data-key="3" title="Heading 3 (3)">###</button>
|
||||||
<span class="toolbar-sep"></span>
|
<span class="toolbar-sep"></span>
|
||||||
<button type="button" class="btn-tool" data-action="code" data-key="C" title="Inline code (C)">`</button>
|
<button type="button" class="btn btn-tool" data-action="code" data-key="C" title="Inline code (C)">`</button>
|
||||||
<button type="button" class="btn-tool" data-action="codeblock" data-key="K" title="Code block (K)">```</button>
|
<button type="button" class="btn btn-tool" data-action="codeblock" data-key="K" title="Code block (K)">```</button>
|
||||||
<span class="toolbar-sep"></span>
|
<span class="toolbar-sep"></span>
|
||||||
<button type="button" class="btn-tool" data-action="link" data-key="L" title="Link (L)">[]</button>
|
<button type="button" class="btn btn-tool" data-action="link" data-key="L" title="Link (L)">[]</button>
|
||||||
<button type="button" class="btn-tool" data-action="quote" data-key="Q" title="Blockquote (Q)">></button>
|
<button type="button" class="btn btn-tool" data-action="quote" data-key="Q" title="Blockquote (Q)">></button>
|
||||||
<button type="button" class="btn-tool" data-action="ul" data-key="U" title="Unordered list (U)">-</button>
|
<button type="button" class="btn btn-tool" data-action="ul" data-key="U" title="Unordered list (U)">-</button>
|
||||||
<button type="button" class="btn-tool" data-action="ol" data-key="O" title="Ordered list (O)">1.</button>
|
<button type="button" class="btn btn-tool" data-action="ol" data-key="O" title="Ordered list (O)">1.</button>
|
||||||
<button type="button" class="btn-tool" data-action="hr" data-key="R" title="Horizontal rule (R)">---</button>
|
<button type="button" class="btn btn-tool" data-action="hr" data-key="R" title="Horizontal rule (R)">---</button>
|
||||||
|
<span class="toolbar-sep"></span>
|
||||||
|
<button type="button" class="btn btn-tool toolbar-dropdown" data-action="tbldrop" title="Table (T)">T▾</button>
|
||||||
|
<button type="button" class="btn btn-tool toolbar-dropdown" data-action="datedrop" title="Insert date (D/W)">D▾</button>
|
||||||
</div>
|
</div>
|
||||||
<textarea name="content" id="editor" autofocus>{{.RawContent}}</textarea>
|
<textarea name="content" id="editor" autofocus>{{.RawContent}}</textarea>
|
||||||
</form>
|
</form>
|
||||||
|
<script src="/_/editor/lists.js"></script>
|
||||||
|
<script src="/_/editor/tables.js"></script>
|
||||||
|
<script src="/_/editor/dates.js"></script>
|
||||||
<script src="/_/editor.js"></script>
|
<script src="/_/editor.js"></script>
|
||||||
{{else}}
|
{{else}}
|
||||||
{{if .Content}}
|
{{if .Content}}
|
||||||
@@ -56,6 +63,9 @@
|
|||||||
{{if or .Content .SpecialContent}}
|
{{if or .Content .SpecialContent}}
|
||||||
<script src="/_/content.js"></script>
|
<script src="/_/content.js"></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{if .Content}}
|
||||||
|
<script src="/_/sections.js"></script>
|
||||||
|
{{end}}
|
||||||
{{if .Entries}}
|
{{if .Entries}}
|
||||||
<div class="listing">
|
<div class="listing">
|
||||||
<div class="listing-header">Contents</div>
|
<div class="listing-header">Contents</div>
|
||||||
|
|||||||
16
assets/sections.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
(function () {
|
||||||
|
var content = document.querySelector('.content');
|
||||||
|
if (!content) return;
|
||||||
|
var headings = content.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||||
|
if (!headings.length) return;
|
||||||
|
|
||||||
|
// Section 0 is pre-heading content, editable via full-page edit.
|
||||||
|
// Sections 1..N each start at a heading; that is the index sent to the server.
|
||||||
|
headings.forEach(function (h, i) {
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = '?edit§ion=' + (i + 1);
|
||||||
|
a.className = 'btn btn-small section-edit';
|
||||||
|
a.textContent = 'edit';
|
||||||
|
h.appendChild(a);
|
||||||
|
});
|
||||||
|
}());
|
||||||
288
assets/style.css
@@ -1,37 +1,38 @@
|
|||||||
/* === CRT Base === */
|
/* === Fonts === */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Iosevka Etoile";
|
||||||
|
src: url("/_/fonts/IosevkaEtoile.woff2") format("woff2");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Iosevka Slab";
|
||||||
|
src: url("/_/fonts/IosevkaSlab.woff2") format("woff2");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Theme === */
|
||||||
|
:root {
|
||||||
|
--bg: #2e2e2e;
|
||||||
|
--bg-panel: #434343;
|
||||||
|
--bg-panel-hover: #585858;
|
||||||
|
--text: #e6e6e6;
|
||||||
|
--text-muted: #cfcfcf;
|
||||||
|
--primary: #87458a;
|
||||||
|
--primary-hover: #d64d95;
|
||||||
|
--secondary: #c48401;
|
||||||
|
--link: #01b6c4;
|
||||||
|
--link-hover: #d6d24d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Base === */
|
||||||
body {
|
body {
|
||||||
background-color: black;
|
background-color: var(--bg);
|
||||||
background-image: radial-gradient(rgba(0, 150, 0, 0.75), black 120%);
|
|
||||||
background-attachment: fixed;
|
|
||||||
background-size: cover;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
color: white;
|
color: var(--text);
|
||||||
font:
|
font:
|
||||||
1rem Inconsolata,
|
1rem "Iosevka Etoile",
|
||||||
monospace;
|
monospace;
|
||||||
text-shadow: 0 0 5px #c8c8c8;
|
|
||||||
}
|
|
||||||
|
|
||||||
body::after {
|
|
||||||
content: "";
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
background: repeating-linear-gradient(
|
|
||||||
0deg,
|
|
||||||
rgba(0, 0, 0, 0.15),
|
|
||||||
rgba(0, 0, 0, 0.15) 1px,
|
|
||||||
transparent 1px,
|
|
||||||
transparent 2px
|
|
||||||
);
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 9999;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -40,24 +41,26 @@ body::after {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
|
||||||
background: #0080ff;
|
|
||||||
text-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Links === */
|
/* === Links === */
|
||||||
a {
|
a {
|
||||||
color: #ffb300;
|
color: var(--text);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
a:hover {
|
a:hover {
|
||||||
color: #ffd54f;
|
color: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content a {
|
||||||
|
color: var(--link);
|
||||||
|
}
|
||||||
|
.content a:hover {
|
||||||
|
color: var(--link-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === Header === */
|
/* === Header === */
|
||||||
header {
|
header {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-bottom: 1px dashed #0a0;
|
border-bottom: 1px dashed var(--secondary);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -79,46 +82,44 @@ header {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sep {
|
.sep {
|
||||||
color: #060;
|
color: var(--secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-btn {
|
.btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: #ffb300;
|
color: var(--text);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.edit-btn::before {
|
.btn::before {
|
||||||
content: "[";
|
content: "[";
|
||||||
|
color: var(--secondary);
|
||||||
}
|
}
|
||||||
.edit-btn::after {
|
.btn::after {
|
||||||
content: "]";
|
content: "]";
|
||||||
|
color: var(--secondary);
|
||||||
}
|
}
|
||||||
.edit-btn:hover {
|
.btn:hover {
|
||||||
color: #ffd54f;
|
color: var(--primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-btn {
|
/* === Button modifiers === */
|
||||||
background: none;
|
/* For inline buttons */
|
||||||
border: none;
|
.btn-small {
|
||||||
color: #ffb300;
|
font-size: 0.65rem;
|
||||||
font: inherit;
|
font-weight: normal;
|
||||||
cursor: pointer;
|
vertical-align: middle;
|
||||||
padding: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
.new-btn::before {
|
|
||||||
content: "[";
|
/* For toolbars */
|
||||||
}
|
.btn-tool {
|
||||||
.new-btn::after {
|
font-size: 0.85rem;
|
||||||
content: "]";
|
padding: 0 0.15rem;
|
||||||
}
|
|
||||||
.new-btn:hover {
|
|
||||||
color: #ffd54f;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === Main === */
|
/* === Main === */
|
||||||
@@ -139,13 +140,13 @@ main {
|
|||||||
.content h4,
|
.content h4,
|
||||||
.content h5,
|
.content h5,
|
||||||
.content h6 {
|
.content h6 {
|
||||||
color: white;
|
color: var(--text);
|
||||||
margin: 1.25rem 0 0.5rem;
|
margin: 1.25rem 0 0.5rem;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
.content h1 {
|
.content h1 {
|
||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
border-bottom: 1px dashed #0a0;
|
border-bottom: 1px dashed var(--secondary);
|
||||||
padding-bottom: 0.25rem;
|
padding-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
.content h2 {
|
.content h2 {
|
||||||
@@ -165,20 +166,20 @@ main {
|
|||||||
margin: 0.25rem 0;
|
margin: 0.25rem 0;
|
||||||
}
|
}
|
||||||
.content blockquote {
|
.content blockquote {
|
||||||
border-left: 3px solid #0a0;
|
border-left: 3px solid var(--secondary);
|
||||||
padding: 0.25rem 1rem;
|
padding: 0.25rem 1rem;
|
||||||
color: #888;
|
color: var(--text-muted);
|
||||||
margin: 0.75rem 0;
|
margin: 0.75rem 0;
|
||||||
}
|
}
|
||||||
.content code {
|
.content code {
|
||||||
font-family: Inconsolata, monospace;
|
font-family: "Iosevka Etoile", monospace;
|
||||||
font-size: 0.875em;
|
font-size: 0.875em;
|
||||||
background: #001a00;
|
background: var(--bg-panel);
|
||||||
padding: 0.1em 0.35em;
|
padding: 0.1em 0.35em;
|
||||||
}
|
}
|
||||||
.content pre {
|
.content pre {
|
||||||
background: #001a00;
|
background: var(--bg-panel);
|
||||||
border: 1px solid #0a0;
|
border: 1px solid var(--secondary);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
margin: 0.75rem 0;
|
margin: 0.75rem 0;
|
||||||
@@ -195,17 +196,17 @@ main {
|
|||||||
}
|
}
|
||||||
.content th,
|
.content th,
|
||||||
.content td {
|
.content td {
|
||||||
border: 1px solid #0a0;
|
border: 1px solid var(--secondary);
|
||||||
padding: 0.4rem 0.75rem;
|
padding: 0.4rem 0.75rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
.content th {
|
.content th {
|
||||||
background: #001a00;
|
background: var(--bg-panel);
|
||||||
color: white;
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.content hr {
|
.content hr {
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px dashed #0a0;
|
border-top: 1px dashed var(--secondary);
|
||||||
margin: 1.5rem 0;
|
margin: 1.5rem 0;
|
||||||
}
|
}
|
||||||
.content img {
|
.content img {
|
||||||
@@ -214,32 +215,31 @@ main {
|
|||||||
|
|
||||||
/* === File listing === */
|
/* === File listing === */
|
||||||
.listing {
|
.listing {
|
||||||
border: 1px solid #0a0;
|
border: 1px solid var(--secondary);
|
||||||
}
|
}
|
||||||
.listing-header {
|
.listing-header {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
color: white;
|
color: var(--text);
|
||||||
border-bottom: 1px solid #0a0;
|
border-bottom: 1px solid var(--secondary);
|
||||||
}
|
}
|
||||||
.listing-item {
|
.listing-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 0.6rem 1rem;
|
padding: 0.6rem 1rem;
|
||||||
border-top: 1px solid #060;
|
border-top: 1px solid var(--secondary);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
.listing-item:hover {
|
.listing-item:hover {
|
||||||
background: #001a00;
|
background: var(--bg-panel-hover);
|
||||||
}
|
}
|
||||||
.listing-item .icon {
|
.listing-item .icon {
|
||||||
width: 1.25rem;
|
width: 1.25rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
filter: drop-shadow(0 0 4px #c8c8c8);
|
|
||||||
}
|
}
|
||||||
.listing-item a {
|
.listing-item a {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -247,13 +247,11 @@ main {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-shadow: inherit;
|
|
||||||
}
|
}
|
||||||
.listing-item .meta {
|
.listing-item .meta {
|
||||||
color: #888;
|
color: var(--text-muted);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-shadow: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === Editor toolbar === */
|
/* === Editor toolbar === */
|
||||||
@@ -261,43 +259,48 @@ main {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
border: 1px solid #0a0;
|
border: 1px solid var(--secondary);
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
padding: 0.4rem 0.6rem;
|
padding: 0.4rem 0.6rem;
|
||||||
background: #001a00;
|
background: var(--bg-panel-hover);
|
||||||
}
|
|
||||||
|
|
||||||
.btn-tool {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: #0f0;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0 0.15rem;
|
|
||||||
text-shadow: 0 0 4px #0a0;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.btn-tool::before {
|
|
||||||
content: "[";
|
|
||||||
color: #060;
|
|
||||||
}
|
|
||||||
.btn-tool::after {
|
|
||||||
content: "]";
|
|
||||||
color: #060;
|
|
||||||
}
|
|
||||||
.btn-tool:hover {
|
|
||||||
color: #fff;
|
|
||||||
text-shadow: 0 0 6px #0f0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-sep {
|
.toolbar-sep {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
background: #060;
|
background: var(--secondary);
|
||||||
margin: 0 0.2rem;
|
margin: 0 0.2rem;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Toolbar dropdowns === */
|
||||||
|
.toolbar-dropdown {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
display: none;
|
||||||
|
z-index: 100;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--secondary);
|
||||||
|
min-width: 9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-dropdown-menu.is-open {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-dropdown-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* === Edit form === */
|
/* === Edit form === */
|
||||||
.edit-form {
|
.edit-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -307,13 +310,11 @@ main {
|
|||||||
textarea {
|
textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 60vh;
|
min-height: 60vh;
|
||||||
background: #000;
|
background: var(--bg);
|
||||||
border: 1px solid #0a0;
|
border: 1px solid var(--secondary);
|
||||||
border-top: none;
|
border-top: none;
|
||||||
color: #0f0;
|
color: var(--text);
|
||||||
caret-color: #0f0;
|
font-family: "Iosevka Slab", monospace;
|
||||||
text-shadow: 0 0 4px #0a0;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
@@ -321,56 +322,12 @@ textarea {
|
|||||||
outline: none;
|
outline: none;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
textarea:focus {
|
|
||||||
border-color: #0f0;
|
|
||||||
box-shadow: 0 0 5px #0a0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-save {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: #ffb300;
|
|
||||||
font: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
text-shadow: inherit;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.btn-save::before {
|
|
||||||
content: "[";
|
|
||||||
}
|
|
||||||
.btn-save::after {
|
|
||||||
content: "]";
|
|
||||||
}
|
|
||||||
.btn-save:hover {
|
|
||||||
color: #ffd54f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-cancel {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: #ffb300;
|
|
||||||
font: inherit;
|
|
||||||
text-shadow: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
text-decoration: none;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
.btn-cancel::before {
|
|
||||||
content: "[";
|
|
||||||
}
|
|
||||||
.btn-cancel::after {
|
|
||||||
content: "]";
|
|
||||||
}
|
|
||||||
.btn-cancel:hover {
|
|
||||||
color: #ffb300;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Diary views === */
|
/* === Diary views === */
|
||||||
.diary-section {
|
.diary-section {
|
||||||
margin: 2rem 0;
|
margin: 2rem 0;
|
||||||
padding-top: 1.5rem;
|
padding-top: 1.5rem;
|
||||||
border-top: 1px dashed #0a0;
|
border-top: 1px dashed var(--secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.diary-section:first-child {
|
.diary-section:first-child {
|
||||||
@@ -379,15 +336,15 @@ textarea:focus {
|
|||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.diary-heading {
|
.diary-section h2 {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
color: white;
|
color: var(--text);
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.diary-photo-count {
|
.diary-photo-count {
|
||||||
color: #888;
|
color: var(--text-muted);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,6 +371,11 @@ textarea:focus {
|
|||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Section edit links === */
|
||||||
|
.section-edit {
|
||||||
|
margin-left: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* === Empty state === */
|
/* === Empty state === */
|
||||||
.empty {
|
.empty {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
@@ -423,7 +385,7 @@ textarea:focus {
|
|||||||
/* === hr === */
|
/* === hr === */
|
||||||
hr {
|
hr {
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px dashed #0a0;
|
border-top: 1px dashed var(--secondary);
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,10 +397,10 @@ hr {
|
|||||||
background: #111;
|
background: #111;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #0a0;
|
background: var(--primary);
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #0f0;
|
background: var(--primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === Responsive === */
|
/* === Responsive === */
|
||||||
|
|||||||
48
diary.go
@@ -77,6 +77,7 @@ type diaryMonthSummary struct {
|
|||||||
type diaryDaySection struct {
|
type diaryDaySection struct {
|
||||||
Heading string
|
Heading string
|
||||||
URL string
|
URL string
|
||||||
|
EditURL string
|
||||||
Content template.HTML
|
Content template.HTML
|
||||||
Photos []diaryPhoto
|
Photos []diaryPhoto
|
||||||
}
|
}
|
||||||
@@ -85,17 +86,43 @@ type diaryYearData struct{ Months []diaryMonthSummary }
|
|||||||
type diaryMonthData struct{ Days []diaryDaySection }
|
type diaryMonthData struct{ Days []diaryDaySection }
|
||||||
type diaryDayData struct{ Photos []diaryPhoto }
|
type diaryDayData struct{ Photos []diaryPhoto }
|
||||||
|
|
||||||
var diaryYearTmpl = template.Must(template.New("diary-year").Parse(
|
var diaryYearTmpl = template.Must(template.ParseFS(assets, "assets/diary/diary-year.html"))
|
||||||
`{{range .Months}}<div class="diary-section"><h2 class="diary-heading"><a href="{{.URL}}">{{.Name}}</a>{{if .PhotoCount}} <span class="diary-photo-count">({{.PhotoCount}} photos)</span>{{end}}</h2></div>{{end}}`,
|
var diaryMonthTmpl = template.Must(template.ParseFS(assets, "assets/diary/diary-month.html"))
|
||||||
))
|
var diaryDayTmpl = template.Must(template.ParseFS(assets, "assets/diary/diary-day.html"))
|
||||||
|
|
||||||
var diaryMonthTmpl = template.Must(template.New("diary-month").Parse(
|
var germanWeekdays = map[time.Weekday]string{
|
||||||
`{{range .Days}}<div class="diary-section"><h2 class="diary-heading">{{if .URL}}<a href="{{.URL}}">{{.Heading}}</a>{{else}}{{.Heading}}{{end}}</h2>{{if .Content}}<div class="content">{{.Content}}</div>{{end}}{{if .Photos}}<div class="diary-photo-grid">{{range .Photos}}<a href="{{.URL}}" target="_blank"><img src="{{.URL}}" alt="{{.Name}}" loading="lazy"></a>{{end}}</div>{{end}}</div>{{end}}`,
|
time.Sunday: "Sonntag",
|
||||||
))
|
time.Monday: "Montag",
|
||||||
|
time.Tuesday: "Dienstag",
|
||||||
|
time.Wednesday: "Mittwoch",
|
||||||
|
time.Thursday: "Donnerstag",
|
||||||
|
time.Friday: "Freitag",
|
||||||
|
time.Saturday: "Samstag",
|
||||||
|
}
|
||||||
|
|
||||||
var diaryDayTmpl = template.Must(template.New("diary-day").Parse(
|
var germanMonths = map[time.Month]string{
|
||||||
`{{if .Photos}}<div class="diary-photo-grid">{{range .Photos}}<a href="{{.URL}}" target="_blank"><img src="{{.URL}}" alt="{{.Name}}" loading="lazy"></a>{{end}}</div>{{end}}`,
|
time.January: "Januar",
|
||||||
))
|
time.February: "Februar",
|
||||||
|
time.March: "März",
|
||||||
|
time.April: "April",
|
||||||
|
time.May: "Mai",
|
||||||
|
time.June: "Juni",
|
||||||
|
time.July: "Juli",
|
||||||
|
time.August: "August",
|
||||||
|
time.September: "September",
|
||||||
|
time.October: "Oktober",
|
||||||
|
time.November: "November",
|
||||||
|
time.December: "Dezember",
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatGermanDate(t time.Time) string {
|
||||||
|
return fmt.Sprintf("%s, %d. %s %d",
|
||||||
|
germanWeekdays[t.Weekday()],
|
||||||
|
t.Day(),
|
||||||
|
germanMonths[t.Month()],
|
||||||
|
t.Year(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
var photoExts = map[string]bool{
|
var photoExts = map[string]bool{
|
||||||
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
|
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
|
||||||
@@ -230,7 +257,7 @@ func renderDiaryMonth(fsPath, urlPath string) template.HTML {
|
|||||||
for _, dayNum := range days {
|
for _, dayNum := range days {
|
||||||
date := time.Date(year, time.Month(monthNum), dayNum, 0, 0, 0, 0, time.UTC)
|
date := time.Date(year, time.Month(monthNum), dayNum, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
heading := date.Format("Monday, January 2")
|
heading := formatGermanDate(date)
|
||||||
dayURL := path.Join(urlPath, fmt.Sprintf("%02d", dayNum)) + "/"
|
dayURL := path.Join(urlPath, fmt.Sprintf("%02d", dayNum)) + "/"
|
||||||
var content template.HTML
|
var content template.HTML
|
||||||
if dirName, ok := dayDirs[dayNum]; ok {
|
if dirName, ok := dayDirs[dayNum]; ok {
|
||||||
@@ -255,6 +282,7 @@ func renderDiaryMonth(fsPath, urlPath string) template.HTML {
|
|||||||
sections = append(sections, diaryDaySection{
|
sections = append(sections, diaryDaySection{
|
||||||
Heading: heading,
|
Heading: heading,
|
||||||
URL: dayURL,
|
URL: dayURL,
|
||||||
|
EditURL: dayURL + "?edit",
|
||||||
Content: content,
|
Content: content,
|
||||||
Photos: photos,
|
Photos: photos,
|
||||||
})
|
})
|
||||||
|
|||||||
41
main.go
@@ -10,10 +10,11 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed assets/*
|
//go:embed assets
|
||||||
var assets embed.FS
|
var assets embed.FS
|
||||||
|
|
||||||
var tmpl = template.Must(template.New("page.html").ParseFS(assets, "assets/page.html"))
|
var tmpl = template.Must(template.New("page.html").ParseFS(assets, "assets/page.html"))
|
||||||
@@ -123,6 +124,16 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
indexPath := filepath.Join(fsPath, "index.md")
|
indexPath := filepath.Join(fsPath, "index.md")
|
||||||
rawMD, _ := os.ReadFile(indexPath)
|
rawMD, _ := os.ReadFile(indexPath)
|
||||||
|
|
||||||
|
// Determine section index (-1 = whole page).
|
||||||
|
sectionIndex := -1
|
||||||
|
if editMode {
|
||||||
|
if s := r.URL.Query().Get("section"); s != "" {
|
||||||
|
if n, err := strconv.Atoi(s); err == nil && n >= 0 {
|
||||||
|
sectionIndex = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var rendered template.HTML
|
var rendered template.HTML
|
||||||
if len(rawMD) > 0 && !editMode {
|
if len(rawMD) > 0 && !editMode {
|
||||||
rendered = renderMarkdown(rawMD)
|
rendered = renderMarkdown(rawMD)
|
||||||
@@ -152,13 +163,22 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
specialContent = special.Content
|
specialContent = special.Content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rawContent := string(rawMD)
|
||||||
|
if editMode && sectionIndex >= 0 {
|
||||||
|
sections := splitSections(rawMD)
|
||||||
|
if sectionIndex < len(sections) {
|
||||||
|
rawContent = string(sections[sectionIndex])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data := pageData{
|
data := pageData{
|
||||||
Title: title,
|
Title: title,
|
||||||
Crumbs: buildCrumbs(urlPath),
|
Crumbs: buildCrumbs(urlPath),
|
||||||
CanEdit: true,
|
CanEdit: true,
|
||||||
EditMode: editMode,
|
EditMode: editMode,
|
||||||
|
SectionIndex: sectionIndex,
|
||||||
PostURL: urlPath,
|
PostURL: urlPath,
|
||||||
RawContent: string(rawMD),
|
RawContent: rawContent,
|
||||||
Content: rendered,
|
Content: rendered,
|
||||||
Entries: entries,
|
Entries: entries,
|
||||||
SpecialContent: specialContent,
|
SpecialContent: specialContent,
|
||||||
@@ -177,6 +197,23 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
|||||||
}
|
}
|
||||||
content := r.FormValue("content")
|
content := r.FormValue("content")
|
||||||
indexPath := filepath.Join(fsPath, "index.md")
|
indexPath := filepath.Join(fsPath, "index.md")
|
||||||
|
|
||||||
|
// If a section index was submitted, splice the edited section back into
|
||||||
|
// the full file rather than replacing the whole document.
|
||||||
|
if s := r.FormValue("section"); s != "" {
|
||||||
|
sectionIndex, err := strconv.Atoi(s)
|
||||||
|
if err != nil || sectionIndex < 0 {
|
||||||
|
http.Error(w, "bad section", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rawMD, _ := os.ReadFile(indexPath)
|
||||||
|
sections := splitSections(rawMD)
|
||||||
|
if sectionIndex < len(sections) {
|
||||||
|
sections[sectionIndex] = []byte(content)
|
||||||
|
}
|
||||||
|
content = string(joinSections(sections))
|
||||||
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(content) == "" {
|
if strings.TrimSpace(content) == "" {
|
||||||
if err := os.Remove(indexPath); err != nil && !os.IsNotExist(err) {
|
if err := os.Remove(indexPath); err != nil && !os.IsNotExist(err) {
|
||||||
http.Error(w, "delete failed: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "delete failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
|||||||
25
render.go
@@ -32,6 +32,7 @@ type pageData struct {
|
|||||||
Crumbs []crumb
|
Crumbs []crumb
|
||||||
CanEdit bool
|
CanEdit bool
|
||||||
EditMode bool
|
EditMode bool
|
||||||
|
SectionIndex int // -1 = whole page; >=0 = section being edited
|
||||||
PostURL string
|
PostURL string
|
||||||
RawContent string
|
RawContent string
|
||||||
Content template.HTML
|
Content template.HTML
|
||||||
@@ -44,6 +45,16 @@ type pageSettings struct {
|
|||||||
Type string
|
Type string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
iconFolder = readIcon("folder")
|
||||||
|
iconDoc = readIcon("doc")
|
||||||
|
iconImage = readIcon("image")
|
||||||
|
iconVideo = readIcon("video")
|
||||||
|
iconAudio = readIcon("audio")
|
||||||
|
iconArchive = readIcon("archive")
|
||||||
|
iconGeneric = readIcon("generic")
|
||||||
|
)
|
||||||
|
|
||||||
// renderMarkdown converts raw markdown to trusted HTML.
|
// renderMarkdown converts raw markdown to trusted HTML.
|
||||||
func renderMarkdown(raw []byte) template.HTML {
|
func renderMarkdown(raw []byte) template.HTML {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
@@ -133,16 +144,10 @@ func listEntries(fsPath, urlPath string) []entry {
|
|||||||
return append(folders, files...)
|
return append(folders, files...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pixel-art SVG icons — outlined, crispEdges, uses currentColor.
|
func readIcon(name string) template.HTML {
|
||||||
const (
|
b, _ := assets.ReadFile("assets/icons/" + name + ".svg")
|
||||||
iconFolder template.HTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges"><path d="M1 6h14v8H1zm0 0V4h5l1 2"/></svg>`
|
return template.HTML(strings.TrimSpace(string(b)))
|
||||||
iconDoc template.HTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges"><path d="M3 1h7l4 4v10H3zM10 1v4h4M5 8h6M5 11h4"/></svg>`
|
}
|
||||||
iconImage template.HTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges"><rect x="1" y="2" width="14" height="12"/><path d="M1 11l4-4 3 3 2-2 5 5"/><rect x="10" y="4" width="2" height="2" fill="currentColor" stroke="none"/></svg>`
|
|
||||||
iconVideo template.HTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges"><rect x="1" y="3" width="14" height="10"/><path d="M6 6v4l5-2z" fill="currentColor" stroke="none"/></svg>`
|
|
||||||
iconAudio template.HTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em" fill="currentColor" stroke="none" shape-rendering="crispEdges"><path d="M2 6h3l4-3v10l-4-3H2z"/><rect x="11" y="5" width="2" height="1"/><rect x="11" y="7" width="3" height="1"/><rect x="11" y="9" width="2" height="1"/></svg>`
|
|
||||||
iconArchive template.HTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges"><rect x="1" y="1" width="14" height="4"/><path d="M1 5v10h14V5M7 3h2"/><rect x="7" y="6" width="2" height="2" fill="currentColor" stroke="none"/><rect x="7" y="9" width="2" height="1" fill="currentColor" stroke="none"/></svg>`
|
|
||||||
iconGeneric template.HTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges"><path d="M3 1h7l4 4v10H3zM10 1v4h4"/></svg>`
|
|
||||||
)
|
|
||||||
|
|
||||||
func fileIcon(name string) template.HTML {
|
func fileIcon(name string) template.HTML {
|
||||||
ext := strings.ToLower(path.Ext(name))
|
ext := strings.ToLower(path.Ext(name))
|
||||||
|
|||||||
31
sections.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"regexp"
|
||||||
|
)
|
||||||
|
|
||||||
|
var sectionHeadingRe = regexp.MustCompile(`(?m)^#{1,6} `)
|
||||||
|
|
||||||
|
// splitSections splits raw markdown into sections.
|
||||||
|
// Section 0 is any content before the first heading.
|
||||||
|
// Each subsequent section begins at a heading line and runs to the next.
|
||||||
|
func splitSections(raw []byte) [][]byte {
|
||||||
|
locs := sectionHeadingRe.FindAllIndex(raw, -1)
|
||||||
|
if len(locs) == 0 {
|
||||||
|
return [][]byte{raw}
|
||||||
|
}
|
||||||
|
sections := make([][]byte, 0, len(locs)+1)
|
||||||
|
prev := 0
|
||||||
|
for _, loc := range locs {
|
||||||
|
sections = append(sections, raw[prev:loc[0]])
|
||||||
|
prev = loc[0]
|
||||||
|
}
|
||||||
|
sections = append(sections, raw[prev:])
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinSections reassembles sections produced by splitSections.
|
||||||
|
func joinSections(sections [][]byte) []byte {
|
||||||
|
return bytes.Join(sections, nil)
|
||||||
|
}
|
||||||