Compare commits

..

2 Commits

Author SHA1 Message Date
ab22952e3d Unify button CSS 2026-04-13 13:50:23 +02:00
316551d263 Update README and AGENTS 2026-04-13 13:11:34 +02:00
4 changed files with 136 additions and 90 deletions

View File

@@ -32,9 +32,13 @@ The entire API surface should stay minimal:
| Method | Path | Behaviour | | Method | Path | Behaviour |
|--------|------|-----------| |--------|------|-----------|
| GET | `/{path}` | If folder: render `index.md` + list contents. If file: serve raw. | | GET | `/{path}/` | If folder exists: render `index.md` + list contents. If not: show empty create prompt. |
| GET | `/{path}?edit` | Mobile-friendly editor with `index.md` content in a textarea | | GET | `/{path}/?edit` | Mobile-friendly editor with `index.md` content in a textarea |
| POST | `/{path}` | Write updated `index.md` to disk | | 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. Do not add endpoints beyond these without a concrete stated need.
@@ -59,6 +63,78 @@ modifiers for new shortcuts.
`data-key` (the `ALT+SHIFT+KEY` shortcut letter). Adding a `data-key` to a button `data-key` (the `ALT+SHIFT+KEY` shortcut letter). Adding a `data-key` to a button
automatically registers its shortcut — no extra wiring needed. 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 ## Auth
- Basic auth is sufficient — this is a personal tool on a private VPN - Basic auth is sufficient — this is a personal tool on a private VPN
- Do not over-engineer access control - Do not over-engineer access control

View File

@@ -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.

View File

@@ -16,32 +16,32 @@
><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}}">
<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)">&gt;</button> <button type="button" class="btn btn-tool" data-action="quote" data-key="Q" title="Blockquote (Q)">&gt;</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>
</div> </div>
<textarea name="content" id="editor" autofocus>{{.RawContent}}</textarea> <textarea name="content" id="editor" autofocus>{{.RawContent}}</textarea>
</form> </form>

View File

@@ -82,7 +82,7 @@ header {
color: #060; color: #060;
} }
.edit-btn { .btn {
background: none; background: none;
border: none; border: none;
color: #ffb300; color: #ffb300;
@@ -90,34 +90,17 @@ header {
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
text-decoration: none; text-decoration: none;
display: inline-block;
white-space: nowrap; white-space: nowrap;
text-shadow: inherit;
} }
.edit-btn::before { .btn::before {
content: "["; content: "[";
} }
.edit-btn::after { .btn::after {
content: "]"; content: "]";
} }
.edit-btn:hover { .btn:hover {
color: #ffd54f;
}
.new-btn {
background: none;
border: none;
color: #ffb300;
font: inherit;
cursor: pointer;
padding: 0;
white-space: nowrap;
}
.new-btn::before {
content: "[";
}
.new-btn::after {
content: "]";
}
.new-btn:hover {
color: #ffd54f; color: #ffd54f;
} }
@@ -268,22 +251,15 @@ main {
} }
.btn-tool { .btn-tool {
background: none;
border: none;
color: #0f0; color: #0f0;
font: inherit;
font-size: 0.85rem; font-size: 0.85rem;
cursor: pointer;
padding: 0 0.15rem; padding: 0 0.15rem;
text-shadow: 0 0 4px #0a0; text-shadow: 0 0 4px #0a0;
white-space: nowrap;
} }
.btn-tool::before { .btn-tool::before {
content: "[";
color: #060; color: #060;
} }
.btn-tool::after { .btn-tool::after {
content: "]";
color: #060; color: #060;
} }
.btn-tool:hover { .btn-tool:hover {
@@ -326,45 +302,6 @@ textarea:focus {
box-shadow: 0 0 5px #0a0; 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 {