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
|
- 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
|
- `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
|
- 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
|
## Development Priorities
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
<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 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 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>
|
||||||
@@ -62,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
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;
|
color: #ffd54f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Button modifiers === */
|
||||||
|
.btn-small {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: normal;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
/* === Main === */
|
/* === Main === */
|
||||||
main {
|
main {
|
||||||
max-width: 860px;
|
max-width: 860px;
|
||||||
@@ -331,7 +338,6 @@ textarea:focus {
|
|||||||
box-shadow: 0 0 5px #0a0;
|
box-shadow: 0 0 5px #0a0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* === Diary views === */
|
/* === Diary views === */
|
||||||
.diary-section {
|
.diary-section {
|
||||||
margin: 2rem 0;
|
margin: 2rem 0;
|
||||||
@@ -380,6 +386,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;
|
||||||
|
|||||||
39
main.go
39
main.go
@@ -10,6 +10,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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