update icon and template handling
79
CLAUDE.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# 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
|
||||
|
||||
## 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
|
||||
|
||||
## 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
|
||||
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}}
|
||||
15
assets/diary/diary-month.html
Normal file
@@ -0,0 +1,15 @@
|
||||
{{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}}
|
||||
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}}
|
||||
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 |
14
diary.go
@@ -85,17 +85,9 @@ 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 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 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 diaryYearTmpl = template.Must(template.ParseFS(assets, "assets/diary/diary-year.html"))
|
||||
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 photoExts = map[string]bool{
|
||||
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
|
||||
|
||||
2
main.go
@@ -13,7 +13,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed assets/*
|
||||
//go:embed assets
|
||||
var assets embed.FS
|
||||
|
||||
var tmpl = template.Must(template.New("page.html").ParseFS(assets, "assets/page.html"))
|
||||
|
||||
24
render.go
@@ -44,6 +44,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 +143,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, _ := assets.ReadFile("assets/icons/" + name + ".svg")
|
||||
return template.HTML(strings.TrimSpace(string(b)))
|
||||
}
|
||||
|
||||
func fileIcon(name string) template.HTML {
|
||||
ext := strings.ToLower(path.Ext(name))
|
||||
|
||||