Compare commits

...

9 Commits

Author SHA1 Message Date
268ffcdd27 WIP Redesign 2026-04-17 10:54:06 +02:00
ec94580ab5 Use regular pico 2026-04-17 10:01:49 +02:00
e0a2d427cc Add pico CSS 2026-04-17 09:49:02 +02:00
8081c2ebd8 Update diary view with new buttons 2026-04-15 12:32:28 +02:00
02fa19272d Allow editing of individual sections 2026-04-15 12:14:40 +02:00
b3ca714597 Improve markdown editor 2026-04-15 09:14:24 +02:00
c7d5db7af7 Remove Agents.md 2026-04-15 07:25:55 +02:00
ed8cb79b0b Update .gitignore 2026-04-14 22:25:19 +02:00
c30c4d3a0d update icon and template handling 2026-04-14 22:24:03 +02:00
27 changed files with 3819 additions and 637 deletions

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ wiki/
# Binaries
datascape
*.exe

163
AGENTS.md
View File

@@ -1,163 +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 `![](photo.jpg)` 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 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 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.
## Code Structure
The backend is split across three files:
| File | Responsibility |
|------|----------------|
| `main.go` | Server setup, routing, `serveDir`, `handlePost`, `pageTypeHandler` interface, `readPageSettings` |
| `render.go` | Shared helpers: markdown rendering, heading extraction, file listing, icons, formatting |
| `diary.go` | Diary page type: all types, templates, and render functions |
When adding a new special folder type, create a new `.go` file. Do not add type-specific
logic to `main.go` or `render.go`.
## Special Folder Types (`pageTypeHandler`)
Folders can opt into special rendering by placing a `.page-settings` file in them.
Format: one `key = value` per line; `#` lines are comments.
```
# example
type = diary
```
The server walks up from the requested path looking for a `.page-settings` file. When
found, it determines the depth of the current path relative to that root and dispatches
to the matching `pageTypeHandler`.
**Interface** (defined in `main.go`):
```go
type specialPage struct {
Content template.HTML
SuppressListing bool
}
type pageTypeHandler interface {
handle(root, fsPath, urlPath string) *specialPage
}
```
`handle` returns `nil` when the handler does not apply. `SuppressListing` hides the
default file/folder table (used when the special content replaces it).
**Registering a new type:** implement the interface in a new file and register via
`init()`:
```go
func init() {
pageTypeHandlers = append(pageTypeHandlers, &myHandler{})
}
```
`serveDir` iterates `pageTypeHandlers` and uses the first non-nil result. It has no
knowledge of specific types.
### Diary type (`diary.go`)
Activated by `type = diary` in a `.page-settings` file. Folder structure:
```
Root/ ← .page-settings (type = diary)
YYYY/ ← depth 1 — year view (month sections + photo counts)
YYYY-MM-DD Description.ext ← photos live here, named with date prefix
MM/ ← depth 2 — month view (day sections with content + photos)
DD/ ← depth 3 — day view (index.md content + photo grid)
index.md
```
Photos are associated to days by parsing the `YYYY-MM-DD` prefix from filenames in the
year folder. No thumbnailing is performed — images are served at full resolution with
`loading="lazy"`. The year view shows only photo counts, not grids, for performance.
## 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

98
CLAUDE.md Normal file
View File

@@ -0,0 +1,98 @@
# 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
### CSS / HTML — Pico CSS
[Pico CSS](https://picocss.com) is the styling framework. Strictly stay within it.
- Do not add other CSS frameworks, utility libraries, or icon fonts.
- Prefer pico's **class-less semantic HTML**: `<button>`, `<section>`, `<hgroup>`, `<header>`, `<nav><ul></ul></nav>`, `<details>`, etc. — let the element itself carry the styling.
- Use `<section>` for thematic blocks. Do **not** use `<article>` (project convention — always reach for `<section>` instead).
- Buttons: native `<button>` or `<a role="button">`. For variants use pico's modifiers (`.secondary`, `.contrast`, `.outline`) — do not invent new button classes. For button groups use `role="group"`.
- Layout: wrap top-level blocks in `.container` for the centered viewport. Use pico's `.grid` when a simple responsive grid is needed.
- Forms: rely on pico's default `<input>`/`<textarea>`/`<select>` styling; use `aria-invalid` for validation states.
- Custom CSS belongs in `style.css` and must only cover what pico does not provide. Reference pico's CSS custom properties (`var(--pico-border-color)`, `var(--pico-muted-color)`, `var(--pico-spacing)`, `var(--pico-card-background-color)`, etc.) — never hardcode colors or spacing.
- Prefer generic descriptive class names (`muted`, `danger`, `listing`, `diary-section`) over element-specific ones. Re-use existing classes before creating new ones.
- `pico.min.css` is the served stylesheet; keep `pico.css` around only as the unminified reference.
## 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

52
assets.go Normal file
View File

@@ -0,0 +1,52 @@
package main
import (
"html/template"
"io/fs"
"os"
"sync"
)
var devMode bool
// assetFS defaults to the embedded FS so package-level initializers (e.g. icon
// vars in render.go) can read assets before main() runs. initAssets() swaps it
// for os.DirFS when -dev is set.
var assetFS fs.FS = assets
func initAssets(dev bool) {
devMode = dev
if dev {
assetFS = os.DirFS(".")
}
}
// readAsset reads a file from the asset FS (embedded in prod, live disk in dev).
func readAsset(path string) ([]byte, error) {
return fs.ReadFile(assetFS, path)
}
// tmplLoader holds a lazily-parsed template. In dev mode it re-parses on every
// get() call so HTML changes are visible without recompiling.
type tmplLoader struct {
name string
patterns []string
once sync.Once
t *template.Template
}
// newTemplate creates a tmplLoader. name is the root template name passed to
// template.New; patterns are the glob/path arguments forwarded to ParseFS.
func newTemplate(name string, patterns ...string) *tmplLoader {
return &tmplLoader{name: name, patterns: patterns}
}
func (l *tmplLoader) get() *template.Template {
if devMode {
return template.Must(template.New(l.name).ParseFS(assetFS, l.patterns...))
}
l.once.Do(func() {
l.t = template.Must(template.New(l.name).ParseFS(assetFS, l.patterns...))
})
return l.t
}

View File

@@ -0,0 +1,9 @@
{{if .Photos}}
<section class="diary-section">
<div class="diary-photo-grid">
{{range .Photos}}
<a href="{{.URL}}" target="_blank"><img src="{{.URL}}" alt="{{.Name}}" loading="lazy"></a>
{{end}}
</div>
</section>
{{end}}

View File

@@ -0,0 +1,16 @@
{{range .Days}}
<section class="diary-section">
<header class="diary-section-header">
<h2>{{if .URL}}<a href="{{.URL}}">{{.Heading}}</a>{{else}}{{.Heading}}{{end}}</h2>
{{if .EditURL}}<a href="{{.EditURL}}" role="button" class="secondary outline section-edit">edit</a>{{end}}
</header>
{{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}}
</section>
{{end}}

View File

@@ -0,0 +1,8 @@
{{range .Months}}
<section class="diary-section">
<hgroup>
<h2><a href="{{.URL}}">{{.Name}}</a></h2>
{{if .PhotoCount}}<p>{{.PhotoCount}} photos</p>{{end}}
</hgroup>
</section>
{{end}}

View File

@@ -2,6 +2,10 @@
var textarea = document.getElementById('editor');
if (!textarea) return;
var form = textarea.closest('form');
// --- DOM helpers ---
function wrap(before, after, placeholder) {
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
@@ -26,7 +30,34 @@
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 = {
save: function () { form.submit(); },
@@ -42,8 +73,20 @@
ul: function () { linePrefix('- '); },
ol: function () { linePrefix('1. '); },
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 = {};
document.querySelectorAll('[data-action]').forEach(function (btn) {
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) {
if (!e.altKey || !e.shiftKey) return;
var action = keyMap[e.key];
@@ -63,4 +110,95 @@
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
View 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
View 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
View 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,
};
})();

7
assets/icons/archive.svg Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View File

@@ -5,50 +5,77 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{.Title}}</title>
<link rel="icon" href="/_/favicon.ico" />
<link rel="stylesheet" href="/_/pico.min.css" />
<link rel="stylesheet" href="/_/style.css" />
<script src="/_/global-shortcuts.js"></script>
</head>
<body>
<header>
<nav class="breadcrumb">
<a href="/"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg></a>
{{range .Crumbs}}<span class="sep">/</span
><a href="{{.URL}}">{{.Name}}</a>{{end}}
</nav>
{{if .EditMode}}
<a class="btn" href="{{.PostURL}}">CANCEL</a>
<button class="btn" type="submit" form="edit-form" data-action="save" data-key="S" title="Save (S)">SAVE</button>
{{else if .CanEdit}}
<button class="btn" onclick="newPage()" title="New page (N)">NEW</button>
<a class="btn" href="?edit" title="Edit page (E)">EDIT</a>
<div class="container-fluid">
<nav>
<nav aria-label="breadcrumb">
<ul>
<li>
<a href="/"><svg style="height: 1rem; width: 1rem; vertical-align: center;" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg></a>
</li>
{{range .Crumbs}}
<li><a href="{{.URL}}">{{.Name}}</a></li>
{{end}}
</ul>
</nav>
<ul>
{{if .EditMode}}
<li><a href="{{.PostURL}}" class="secondary">Cancel</a></li>
<li><a href="#" onclick="document.getElementById('edit-form').submit()" data-action="save" data-key="S" title="Save (S)">Save</a></li>
{{else if .CanEdit}}
<li><a href="#" onclick="newPage()" class="secondary" title="New page (N)">New</a></li>
<li><a href="?edit" title="Edit page (E)">Edit</a></li>
{{end}}
</ul>
</nav>
</div>
</header>
<main>
<main class="container">
{{if .EditMode}}
<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">
<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>
<span class="toolbar-sep"></span>
<button type="button" class="btn btn-tool" data-action="h1" data-key="1" title="Heading 1 (1)">#</button>
<button type="button" class="btn btn-tool" data-action="h2" data-key="2" title="Heading 2 (2)">##</button>
<button type="button" class="btn btn-tool" data-action="h3" data-key="3" title="Heading 3 (3)">###</button>
<span class="toolbar-sep"></span>
<button type="button" class="btn btn-tool" data-action="code" data-key="C" title="Inline code (C)">`</button>
<button type="button" class="btn btn-tool" data-action="codeblock" data-key="K" title="Code block (K)">```</button>
<span class="toolbar-sep"></span>
<button type="button" class="btn btn-tool" data-action="link" data-key="L" title="Link (L)">[]</button>
<button type="button" class="btn btn-tool" data-action="quote" data-key="Q" title="Blockquote (Q)">&gt;</button>
<button type="button" class="btn btn-tool" data-action="ul" data-key="U" title="Unordered list (U)">-</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 btn-tool" data-action="hr" data-key="R" title="Horizontal rule (R)">---</button>
<div role="group">
<button type="button" data-action="bold" data-key="B" title="Bold (B)">B</button>
<button type="button" data-action="italic" data-key="I" title="Italic (I)"><i>I</i></button>
</div>
<div role="group">
<button type="button" data-action="h1" data-key="1" title="Heading 1 (1)">H1</button>
<button type="button" data-action="h2" data-key="2" title="Heading 2 (2)">H2</button>
<button type="button" data-action="h3" data-key="3" title="Heading 3 (3)">H3</button>
</div>
<div role="group">
<button type="button" data-action="code" data-key="C" title="Inline code (C)">`</button>
<button type="button" data-action="codeblock" data-key="K" title="Code block (K)">```</button>
</div>
<div role="group">
<button type="button" data-action="link" data-key="L" title="Link (L)">[ ]</button>
<button type="button" data-action="quote" data-key="Q" title="Blockquote (Q)">&gt;</button>
<button type="button" data-action="ul" data-key="U" title="Unordered list (U)">&bull;</button>
<button type="button" data-action="ol" data-key="O" title="Ordered list (O)">1.</button>
<button type="button" data-action="hr" data-key="R" title="Horizontal rule (R)"></button>
</div>
<div role="group">
<button type="button" class="toolbar-dropdown" data-action="tbldrop" title="Table (T)">T&#9662;</button>
<button type="button" class="toolbar-dropdown" data-action="datedrop" title="Insert date (D/W)">D&#9662;</button>
</div>
</div>
<textarea name="content" id="editor" autofocus>{{.RawContent}}</textarea>
</form>
<script src="/_/editor/lists.js"></script>
<script src="/_/editor/tables.js"></script>
<script src="/_/editor/dates.js"></script>
<script src="/_/editor.js"></script>
{{else}}
{{if .Content}}
<div class="content">{{.Content}}</div>
<section class="content">{{.Content}}</section>
{{end}}
{{if .SpecialContent}}
<div class="diary">{{.SpecialContent}}</div>
@@ -56,20 +83,34 @@
{{if or .Content .SpecialContent}}
<script src="/_/content.js"></script>
{{end}}
{{if .Entries}}
<div class="listing">
<div class="listing-header">Contents</div>
{{range .Entries}}
<div class="listing-item">
<span class="icon">{{.Icon}}</span>
<a href="{{.URL}}">{{.Name}}</a>
<span class="meta">{{.Meta}}</span>
</div>
{{if .Content}}
<script src="/_/sections.js"></script>
{{end}}
</div>
{{if .Entries}}
<section class="listing">
<header>Contents</header>
<table>
<thead>
<tr>
<th scope="col"></th>
<th scope="col">Name</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{{range .Entries}}
<tr>
<td>{{.Icon}}</td>
<td><a href="{{.URL}}">{{.Name}}</a></td>
<td>{{.Meta}}</td>
</tr>
{{end}}
</tbody>
</table>
</section>
{{else if not .Content}}
{{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>
{{end}}
{{end}}
{{end}}

2835
assets/pico.css Normal file

File diff suppressed because it is too large Load Diff

4
assets/pico.min.css vendored Normal file

File diff suppressed because one or more lines are too long

16
assets/sections.js Normal file
View 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&section=' + (i + 1);
a.className = 'secondary';
a.textContent = 'edit';
h.appendChild(a);
});
}());

View File

@@ -1,392 +1 @@
/* === CRT Base === */
body {
background-color: black;
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;
margin: 0;
overflow: auto;
padding: 0;
color: white;
font:
1rem Inconsolata,
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;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
::selection {
background: #0080ff;
text-shadow: none;
}
/* === Links === */
a {
color: #ffb300;
text-decoration: none;
}
a:hover {
color: #ffd54f;
}
/* === Header === */
header {
padding: 0.75rem 1rem;
border-bottom: 1px dashed #0a0;
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 0.25rem;
flex-wrap: wrap;
flex: 1;
}
.logo {
width: 1.1em;
height: 1.1em;
vertical-align: center;
}
.sep {
color: #060;
}
.btn {
background: none;
border: none;
color: #ffb300;
font: inherit;
cursor: pointer;
padding: 0;
text-decoration: none;
display: inline-block;
white-space: nowrap;
text-shadow: inherit;
}
.btn::before {
content: "[";
}
.btn::after {
content: "]";
}
.btn:hover {
color: #ffd54f;
}
/* === Main === */
main {
max-width: 860px;
margin: 0 auto;
padding: 1.5rem 1rem;
}
/* === Markdown content === */
.content {
margin-bottom: 2rem;
}
.content h1,
.content h2,
.content h3,
.content h4,
.content h5,
.content h6 {
color: white;
margin: 1.25rem 0 0.5rem;
line-height: 1.3;
}
.content h1 {
font-size: 1.75rem;
border-bottom: 1px dashed #0a0;
padding-bottom: 0.25rem;
}
.content h2 {
font-size: 1.4rem;
}
.content h3 {
font-size: 1.15rem;
}
.content p {
margin: 0.75rem 0;
}
.content ul,
.content ol {
margin: 0.75rem 0 0.75rem 1.5rem;
}
.content li {
margin: 0.25rem 0;
}
.content blockquote {
border-left: 3px solid #0a0;
padding: 0.25rem 1rem;
color: #888;
margin: 0.75rem 0;
}
.content code {
font-family: Inconsolata, monospace;
font-size: 0.875em;
background: #001a00;
padding: 0.1em 0.35em;
}
.content pre {
background: #001a00;
border: 1px solid #0a0;
padding: 1rem;
overflow-x: auto;
margin: 0.75rem 0;
}
.content pre code {
background: none;
padding: 0;
}
.content table {
width: 100%;
border-collapse: collapse;
margin: 0.75rem 0;
font-size: 0.9rem;
}
.content th,
.content td {
border: 1px solid #0a0;
padding: 0.4rem 0.75rem;
text-align: left;
}
.content th {
background: #001a00;
color: white;
}
.content hr {
border: none;
border-top: 1px dashed #0a0;
margin: 1.5rem 0;
}
.content img {
max-width: 100%;
}
/* === File listing === */
.listing {
border: 1px solid #0a0;
}
.listing-header {
padding: 0.5rem 1rem;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: white;
border-bottom: 1px solid #0a0;
}
.listing-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 1rem;
border-top: 1px solid #060;
font-size: 0.95rem;
}
.listing-item:hover {
background: #001a00;
}
.listing-item .icon {
width: 1.25rem;
text-align: center;
flex-shrink: 0;
filter: drop-shadow(0 0 4px #c8c8c8);
}
.listing-item a {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: inherit;
text-shadow: inherit;
}
.listing-item .meta {
color: #888;
font-size: 0.8rem;
white-space: nowrap;
text-shadow: none;
}
/* === Editor toolbar === */
.editor-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
border: 1px solid #0a0;
border-bottom: none;
padding: 0.4rem 0.6rem;
background: #001a00;
}
.btn-tool {
color: #0f0;
font-size: 0.85rem;
padding: 0 0.15rem;
text-shadow: 0 0 4px #0a0;
}
.btn-tool::before {
color: #060;
}
.btn-tool::after {
color: #060;
}
.btn-tool:hover {
color: #fff;
text-shadow: 0 0 6px #0f0;
}
.toolbar-sep {
width: 1px;
background: #060;
margin: 0 0.2rem;
align-self: stretch;
}
/* === Edit form === */
.edit-form {
display: flex;
flex-direction: column;
}
textarea {
width: 100%;
min-height: 60vh;
background: #000;
border: 1px solid #0a0;
border-top: none;
color: #0f0;
caret-color: #0f0;
text-shadow: 0 0 4px #0a0;
font: inherit;
font-size: 0.9rem;
line-height: 1.6;
padding: 1rem;
resize: vertical;
outline: none;
box-sizing: border-box;
}
textarea:focus {
border-color: #0f0;
box-shadow: 0 0 5px #0a0;
}
/* === Diary views === */
.diary-section {
margin: 2rem 0;
padding-top: 1.5rem;
border-top: 1px dashed #0a0;
}
.diary-section:first-child {
border-top: none;
padding-top: 0;
margin-top: 0;
}
.diary-heading {
font-size: 1.2rem;
color: white;
margin-bottom: 0.75rem;
font-weight: normal;
}
.diary-photo-count {
color: #888;
font-size: 0.85rem;
}
.diary-photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.4rem;
margin-top: 0.75rem;
}
.diary-photo-grid a {
display: block;
line-height: 0;
}
.diary-photo-grid img {
width: 100%;
height: 140px;
object-fit: cover;
display: block;
}
.diary-section .content {
margin-bottom: 0.75rem;
}
/* === Empty state === */
.empty {
padding: 1rem;
text-align: center;
}
/* === hr === */
hr {
border: none;
border-top: 1px dashed #0a0;
margin: 1rem 0;
}
/* === Scrollbars === */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #111;
}
::-webkit-scrollbar-thumb {
background: #0a0;
}
::-webkit-scrollbar-thumb:hover {
background: #0f0;
}
/* === Responsive === */
@media (max-width: 600px) {
header {
padding: 0.5rem 0.75rem;
}
main {
padding: 1rem 0.75rem;
}
textarea {
min-height: 50vh;
}
}
/* === Pico customizations === */

View File

@@ -77,6 +77,7 @@ type diaryMonthSummary struct {
type diaryDaySection struct {
Heading string
URL string
EditURL string
Content template.HTML
Photos []diaryPhoto
}
@@ -85,17 +86,43 @@ type diaryYearData struct{ Months []diaryMonthSummary }
type diaryMonthData struct{ Days []diaryDaySection }
type diaryDayData struct{ Photos []diaryPhoto }
var diaryYearTmpl = template.Must(template.New("diary-year").Parse(
`{{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 diaryYearTmpl = newTemplate("diary-year.html", "assets/diary/diary-year.html")
var diaryMonthTmpl = newTemplate("diary-month.html", "assets/diary/diary-month.html")
var diaryDayTmpl = newTemplate("diary-day.html", "assets/diary/diary-day.html")
var diaryMonthTmpl = template.Must(template.New("diary-month").Parse(
`{{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}}`,
))
var germanWeekdays = map[time.Weekday]string{
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(
`{{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}}`,
))
var germanMonths = map[time.Month]string{
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{
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
@@ -171,7 +198,7 @@ func renderDiaryYear(fsPath, urlPath string) template.HTML {
}
var buf bytes.Buffer
if err := diaryYearTmpl.Execute(&buf, diaryYearData{Months: months}); err != nil {
if err := diaryYearTmpl.get().Execute(&buf, diaryYearData{Months: months}); err != nil {
log.Printf("diary year template: %v", err)
return ""
}
@@ -230,7 +257,7 @@ func renderDiaryMonth(fsPath, urlPath string) template.HTML {
for _, dayNum := range days {
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)) + "/"
var content template.HTML
if dirName, ok := dayDirs[dayNum]; ok {
@@ -255,13 +282,14 @@ func renderDiaryMonth(fsPath, urlPath string) template.HTML {
sections = append(sections, diaryDaySection{
Heading: heading,
URL: dayURL,
EditURL: dayURL + "?edit",
Content: content,
Photos: photos,
})
}
var buf bytes.Buffer
if err := diaryMonthTmpl.Execute(&buf, diaryMonthData{Days: sections}); err != nil {
if err := diaryMonthTmpl.get().Execute(&buf, diaryMonthData{Days: sections}); err != nil {
log.Printf("diary month template: %v", err)
return ""
}
@@ -301,7 +329,7 @@ func renderDiaryDay(fsPath, urlPath string) template.HTML {
}
var buf bytes.Buffer
if err := diaryDayTmpl.Execute(&buf, diaryDayData{Photos: photos}); err != nil {
if err := diaryDayTmpl.get().Execute(&buf, diaryDayData{Photos: photos}); err != nil {
log.Printf("diary day template: %v", err)
return ""
}

50
main.go
View File

@@ -10,13 +10,14 @@ import (
"os"
"path"
"path/filepath"
"strconv"
"strings"
)
//go:embed assets/*
//go:embed assets
var assets embed.FS
var tmpl = template.Must(template.New("page.html").ParseFS(assets, "assets/page.html"))
var tmpl = newTemplate("page.html", "assets/page.html")
// specialPage is the result returned by a pageTypeHandler.
// Content is injected into the page after the standard markdown content.
@@ -40,8 +41,11 @@ func main() {
wikiDir := flag.String("dir", "./wiki", "wiki root directory")
user := flag.String("user", "", "basic auth username (empty = no auth)")
pass := flag.String("pass", "", "basic auth password")
dev := flag.Bool("dev", false, "serve assets from disk (no recompile needed for HTML/CSS changes)")
flag.Parse()
initAssets(*dev)
root, err := filepath.Abs(*wikiDir)
if err != nil {
log.Fatal(err)
@@ -52,7 +56,7 @@ func main() {
h := &handler{root: root, user: *user, pass: *pass}
staticFS, _ := fs.Sub(assets, "assets")
staticFS, _ := fs.Sub(assetFS, "assets")
http.Handle("/_/", http.StripPrefix("/_/", http.FileServer(http.FS(staticFS))))
http.Handle("/", h)
@@ -123,6 +127,16 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
indexPath := filepath.Join(fsPath, "index.md")
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
if len(rawMD) > 0 && !editMode {
rendered = renderMarkdown(rawMD)
@@ -152,20 +166,29 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
specialContent = special.Content
}
rawContent := string(rawMD)
if editMode && sectionIndex >= 0 {
sections := splitSections(rawMD)
if sectionIndex < len(sections) {
rawContent = string(sections[sectionIndex])
}
}
data := pageData{
Title: title,
Crumbs: buildCrumbs(urlPath),
CanEdit: true,
EditMode: editMode,
SectionIndex: sectionIndex,
PostURL: urlPath,
RawContent: string(rawMD),
RawContent: rawContent,
Content: rendered,
Entries: entries,
SpecialContent: specialContent,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
if err := tmpl.get().Execute(w, data); err != nil {
log.Printf("template error: %v", err)
}
}
@@ -177,6 +200,23 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
}
content := r.FormValue("content")
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 err := os.Remove(indexPath); err != nil && !os.IsNotExist(err) {
http.Error(w, "delete failed: "+err.Error(), http.StatusInternalServerError)

View File

@@ -32,6 +32,7 @@ type pageData struct {
Crumbs []crumb
CanEdit bool
EditMode bool
SectionIndex int // -1 = whole page; >=0 = section being edited
PostURL string
RawContent string
Content template.HTML
@@ -44,6 +45,16 @@ type pageSettings struct {
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.
func renderMarkdown(raw []byte) template.HTML {
var buf bytes.Buffer
@@ -133,16 +144,10 @@ func listEntries(fsPath, urlPath string) []entry {
return append(folders, files...)
}
// Pixel-art SVG icons — outlined, crispEdges, uses currentColor.
const (
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>`
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 readIcon(name string) template.HTML {
b, _ := readAsset("assets/icons/" + name + ".svg")
return template.HTML(strings.TrimSpace(string(b)))
}
func fileIcon(name string) template.HTML {
ext := strings.ToLower(path.Ext(name))

31
sections.go Normal file
View 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)
}