Allow editing of individual sections
This commit is contained in:
@@ -54,6 +54,8 @@ Prefer separate, human-readable `.html` files over inlined HTML strings in Go. E
|
||||
- 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
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<main>
|
||||
{{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>
|
||||
@@ -62,6 +63,9 @@
|
||||
{{if or .Content .SpecialContent}}
|
||||
<script src="/_/content.js"></script>
|
||||
{{end}}
|
||||
{{if .Content}}
|
||||
<script src="/_/sections.js"></script>
|
||||
{{end}}
|
||||
{{if .Entries}}
|
||||
<div class="listing">
|
||||
<div class="listing-header">Contents</div>
|
||||
|
||||
16
assets/sections.js
Normal file
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);
|
||||
});
|
||||
}());
|
||||
@@ -104,6 +104,13 @@ header {
|
||||
color: #ffd54f;
|
||||
}
|
||||
|
||||
/* === Button modifiers === */
|
||||
.btn-small {
|
||||
font-size: 0.65rem;
|
||||
font-weight: normal;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* === Main === */
|
||||
main {
|
||||
max-width: 860px;
|
||||
@@ -331,7 +338,6 @@ textarea:focus {
|
||||
box-shadow: 0 0 5px #0a0;
|
||||
}
|
||||
|
||||
|
||||
/* === Diary views === */
|
||||
.diary-section {
|
||||
margin: 2rem 0;
|
||||
@@ -380,6 +386,11 @@ textarea:focus {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* === Section edit links === */
|
||||
.section-edit {
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
/* === Empty state === */
|
||||
.empty {
|
||||
padding: 1rem;
|
||||
|
||||
39
main.go
39
main.go
@@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -123,6 +124,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,13 +163,22 @@ 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,
|
||||
@@ -177,6 +197,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
31
sections.go
Normal file
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)
|
||||
}
|
||||
Reference in New Issue
Block a user