Diary feature V1
This commit is contained in:
@@ -45,10 +45,17 @@
|
||||
<textarea name="content" id="editor" autofocus>{{.RawContent}}</textarea>
|
||||
</form>
|
||||
<script src="/_/editor.js"></script>
|
||||
{{else}} {{if .Content}}
|
||||
{{else}}
|
||||
{{if .Content}}
|
||||
<div class="content">{{.Content}}</div>
|
||||
{{end}}
|
||||
{{if .DiaryContent}}
|
||||
<div class="diary">{{.DiaryContent}}</div>
|
||||
{{end}}
|
||||
{{if or .Content .DiaryContent}}
|
||||
<script src="/_/content.js"></script>
|
||||
{{end}} {{if .Entries}}
|
||||
{{end}}
|
||||
{{if .Entries}}
|
||||
<div class="listing">
|
||||
<div class="listing-header">Contents</div>
|
||||
{{range .Entries}}
|
||||
@@ -60,8 +67,11 @@
|
||||
{{end}}
|
||||
</div>
|
||||
{{else if not .Content}}
|
||||
{{if not .DiaryContent}}
|
||||
<p class="empty">Empty folder — <a href="?edit">[CREATE]</a></p>
|
||||
{{end}} {{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -345,6 +345,54 @@ textarea:focus {
|
||||
color: #ffb300;
|
||||
}
|
||||
|
||||
/* === 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;
|
||||
|
||||
393
main.go
393
main.go
@@ -9,11 +9,14 @@ import (
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
@@ -32,6 +35,19 @@ var md = goldmark.New(
|
||||
|
||||
var tmpl = template.Must(template.New("page.html").ParseFS(assets, "assets/page.html"))
|
||||
|
||||
// Diary sub-templates — executed into a buffer and injected as DiaryContent.
|
||||
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}}`,
|
||||
))
|
||||
|
||||
type crumb struct{ Name, URL string }
|
||||
type entry struct {
|
||||
Icon template.HTML
|
||||
@@ -47,8 +63,38 @@ type pageData struct {
|
||||
RawContent string
|
||||
Content template.HTML
|
||||
Entries []entry
|
||||
DiaryContent template.HTML
|
||||
}
|
||||
|
||||
// pageSettings holds the parsed contents of a .page-settings file.
|
||||
type pageSettings struct {
|
||||
Type string
|
||||
}
|
||||
|
||||
// diaryPhoto is a photo file whose name starts with a YYYY-MM-DD date prefix.
|
||||
type diaryPhoto struct {
|
||||
Date time.Time
|
||||
Name string
|
||||
URL string
|
||||
}
|
||||
|
||||
type diaryMonthSummary struct {
|
||||
Name string
|
||||
URL string
|
||||
PhotoCount int
|
||||
}
|
||||
|
||||
type diaryDaySection struct {
|
||||
Heading string
|
||||
URL string
|
||||
Content template.HTML
|
||||
Photos []diaryPhoto
|
||||
}
|
||||
|
||||
type diaryYearData struct{ Months []diaryMonthSummary }
|
||||
type diaryMonthData struct{ Days []diaryDaySection }
|
||||
type diaryDayData struct{ Photos []diaryPhoto }
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", ":8080", "listen address")
|
||||
wikiDir := flag.String("dir", "./wiki", "wiki root directory")
|
||||
@@ -90,7 +136,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
urlPath := path.Clean("/" + r.URL.Path)
|
||||
|
||||
fsPath := filepath.Join(h.root, filepath.FromSlash(urlPath))
|
||||
fsPath := filepath.Join(h.root, filepath.FromSlash(urlPath))
|
||||
|
||||
// Security: ensure the resolved path stays within root.
|
||||
rel, err := filepath.Rel(h.root, fsPath)
|
||||
@@ -101,6 +147,10 @@ fsPath := filepath.Join(h.root, filepath.FromSlash(urlPath))
|
||||
|
||||
info, err := os.Stat(fsPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) && strings.HasSuffix(r.URL.Path, "/") {
|
||||
h.serveDir(w, r, urlPath, fsPath)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
@@ -128,19 +178,36 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
||||
|
||||
var rendered template.HTML
|
||||
if len(rawMD) > 0 && !editMode {
|
||||
var buf bytes.Buffer
|
||||
if err := md.Convert(rawMD, &buf); err == nil {
|
||||
rendered = template.HTML(buf.String())
|
||||
}
|
||||
rendered = renderMarkdown(rawMD)
|
||||
}
|
||||
|
||||
diaryDepth, isDiary := h.findDiaryContext(fsPath)
|
||||
|
||||
// For diary sub-pages (depth > 0) the diary content replaces the file listing.
|
||||
var entries []entry
|
||||
if !editMode {
|
||||
if !editMode && (!isDiary || diaryDepth == 0) {
|
||||
entries = listEntries(fsPath, urlPath)
|
||||
}
|
||||
|
||||
var diaryContent template.HTML
|
||||
if !editMode && isDiary {
|
||||
switch diaryDepth {
|
||||
case 1:
|
||||
diaryContent = h.renderDiaryYear(fsPath, urlPath)
|
||||
case 2:
|
||||
diaryContent = h.renderDiaryMonth(fsPath, urlPath)
|
||||
case 3:
|
||||
diaryContent = h.renderDiaryDay(fsPath, urlPath)
|
||||
}
|
||||
}
|
||||
|
||||
title := pageTitle(urlPath)
|
||||
if h := extractFirstHeading(rawMD); h != "" {
|
||||
title = h
|
||||
}
|
||||
|
||||
data := pageData{
|
||||
Title: pageTitle(urlPath),
|
||||
Title: title,
|
||||
Crumbs: buildCrumbs(urlPath),
|
||||
CanEdit: true,
|
||||
EditMode: editMode,
|
||||
@@ -148,6 +215,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
||||
RawContent: string(rawMD),
|
||||
Content: rendered,
|
||||
Entries: entries,
|
||||
DiaryContent: diaryContent,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
@@ -169,6 +237,10 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := os.MkdirAll(fsPath, 0755); err != nil {
|
||||
http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(indexPath, []byte(content), 0644); err != nil {
|
||||
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -177,6 +249,313 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
||||
http.Redirect(w, r, urlPath, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// readPageSettings parses a .page-settings file in dir.
|
||||
// Returns nil if the file does not exist.
|
||||
// Format: one "key = value" pair per line; lines starting with # are comments.
|
||||
func readPageSettings(dir string) *pageSettings {
|
||||
data, err := os.ReadFile(filepath.Join(dir, ".page-settings"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
s := &pageSettings{}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(parts[0]) {
|
||||
case "type":
|
||||
s.Type = strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// findDiaryContext walks up from fsPath toward h.root looking for a
|
||||
// .page-settings file with type=diary. Returns the depth of fsPath
|
||||
// relative to the diary root, and whether a diary root was found at all.
|
||||
// depth=0 means fsPath itself is the diary root.
|
||||
func (h *handler) findDiaryContext(fsPath string) (int, bool) {
|
||||
current := fsPath
|
||||
for depth := 0; ; depth++ {
|
||||
s := readPageSettings(current)
|
||||
if s != nil && s.Type == "diary" {
|
||||
return depth, true
|
||||
}
|
||||
if current == h.root {
|
||||
break
|
||||
}
|
||||
parent := filepath.Dir(current)
|
||||
if parent == current {
|
||||
break
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// yearPhotos returns all photos in yearFsPath whose filename starts with
|
||||
// a YYYY-MM-DD date prefix. yearURLPath is the corresponding URL prefix.
|
||||
var photoExts = map[string]bool{
|
||||
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
|
||||
}
|
||||
|
||||
func yearPhotos(yearFsPath, yearURLPath string) []diaryPhoto {
|
||||
entries, err := os.ReadDir(yearFsPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var photos []diaryPhoto
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if !photoExts[strings.ToLower(filepath.Ext(name))] {
|
||||
continue
|
||||
}
|
||||
if len(name) < 10 {
|
||||
continue
|
||||
}
|
||||
t, err := time.Parse("2006-01-02", name[:10])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
photos = append(photos, diaryPhoto{
|
||||
Date: t,
|
||||
Name: name,
|
||||
URL: path.Join(yearURLPath, url.PathEscape(name)),
|
||||
})
|
||||
}
|
||||
return photos
|
||||
}
|
||||
|
||||
// extractFirstHeading returns the text of the first ATX heading in raw markdown,
|
||||
// or an empty string if none is found.
|
||||
func extractFirstHeading(raw []byte) string {
|
||||
for _, line := range strings.SplitN(string(raw), "\n", 50) {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(strings.TrimLeft(trimmed, "#"))
|
||||
if text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// stripFirstHeading removes the first ATX heading line from raw markdown.
|
||||
func stripFirstHeading(raw []byte) []byte {
|
||||
lines := strings.Split(string(raw), "\n")
|
||||
for i, line := range lines {
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "#") {
|
||||
result := append(lines[:i:i], lines[i+1:]...)
|
||||
return []byte(strings.TrimLeft(strings.Join(result, "\n"), "\n"))
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// renderMarkdown converts raw markdown to trusted HTML.
|
||||
func renderMarkdown(raw []byte) template.HTML {
|
||||
var buf bytes.Buffer
|
||||
if err := md.Convert(raw, &buf); err != nil {
|
||||
return ""
|
||||
}
|
||||
return template.HTML(buf.String())
|
||||
}
|
||||
|
||||
// parentURL returns the parent URL of a slash-terminated URL path.
|
||||
func parentURL(urlPath string) string {
|
||||
parent := path.Dir(strings.TrimSuffix(urlPath, "/"))
|
||||
if parent == "." || parent == "/" {
|
||||
return "/"
|
||||
}
|
||||
return parent + "/"
|
||||
}
|
||||
|
||||
// renderDiaryYear renders month sections with photo counts for a year folder.
|
||||
func (h *handler) renderDiaryYear(fsPath, urlPath string) template.HTML {
|
||||
yearStr := filepath.Base(fsPath)
|
||||
year, err := strconv.Atoi(yearStr)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
photos := yearPhotos(fsPath, urlPath)
|
||||
|
||||
entries, err := os.ReadDir(fsPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var months []diaryMonthSummary
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
monthNum, err := strconv.Atoi(e.Name())
|
||||
if err != nil || monthNum < 1 || monthNum > 12 {
|
||||
continue
|
||||
}
|
||||
count := 0
|
||||
for _, p := range photos {
|
||||
if p.Date.Year() == year && int(p.Date.Month()) == monthNum {
|
||||
count++
|
||||
}
|
||||
}
|
||||
monthDate := time.Date(year, time.Month(monthNum), 1, 0, 0, 0, 0, time.UTC)
|
||||
months = append(months, diaryMonthSummary{
|
||||
Name: monthDate.Format("January 2006"),
|
||||
URL: path.Join(urlPath, e.Name()) + "/",
|
||||
PhotoCount: count,
|
||||
})
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := diaryYearTmpl.Execute(&buf, diaryYearData{Months: months}); err != nil {
|
||||
log.Printf("diary year template: %v", err)
|
||||
return ""
|
||||
}
|
||||
return template.HTML(buf.String())
|
||||
}
|
||||
|
||||
// renderDiaryMonth renders a section per day, each with its markdown content
|
||||
// and photos sourced from the parent year folder.
|
||||
func (h *handler) renderDiaryMonth(fsPath, urlPath string) template.HTML {
|
||||
yearFsPath := filepath.Dir(fsPath)
|
||||
yearURLPath := parentURL(urlPath)
|
||||
|
||||
year, err := strconv.Atoi(filepath.Base(yearFsPath))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
monthNum, err := strconv.Atoi(filepath.Base(fsPath))
|
||||
if err != nil || monthNum < 1 || monthNum > 12 {
|
||||
return ""
|
||||
}
|
||||
|
||||
allPhotos := yearPhotos(yearFsPath, yearURLPath)
|
||||
var monthPhotos []diaryPhoto
|
||||
for _, p := range allPhotos {
|
||||
if p.Date.Year() == year && int(p.Date.Month()) == monthNum {
|
||||
monthPhotos = append(monthPhotos, p)
|
||||
}
|
||||
}
|
||||
|
||||
// Collect day numbers from subdirectories and from photo filenames.
|
||||
daySet := map[int]bool{}
|
||||
dayDirs := map[int]string{} // day number → actual directory name
|
||||
entries, _ := os.ReadDir(fsPath)
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
d, err := strconv.Atoi(e.Name())
|
||||
if err != nil || d < 1 || d > 31 {
|
||||
continue
|
||||
}
|
||||
daySet[d] = true
|
||||
dayDirs[d] = e.Name()
|
||||
}
|
||||
for _, p := range monthPhotos {
|
||||
daySet[p.Date.Day()] = true
|
||||
}
|
||||
|
||||
days := make([]int, 0, len(daySet))
|
||||
for d := range daySet {
|
||||
days = append(days, d)
|
||||
}
|
||||
sort.Ints(days)
|
||||
|
||||
var sections []diaryDaySection
|
||||
for _, dayNum := range days {
|
||||
date := time.Date(year, time.Month(monthNum), dayNum, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
heading := date.Format("Monday, January 2")
|
||||
dayURL := path.Join(urlPath, fmt.Sprintf("%02d", dayNum)) + "/"
|
||||
var content template.HTML
|
||||
if dirName, ok := dayDirs[dayNum]; ok {
|
||||
dayURL = path.Join(urlPath, dirName) + "/"
|
||||
dayFsPath := filepath.Join(fsPath, dirName)
|
||||
if raw, err := os.ReadFile(filepath.Join(dayFsPath, "index.md")); err == nil && len(raw) > 0 {
|
||||
if h := extractFirstHeading(raw); h != "" {
|
||||
heading = h
|
||||
raw = stripFirstHeading(raw)
|
||||
}
|
||||
content = renderMarkdown(raw)
|
||||
}
|
||||
}
|
||||
|
||||
var photos []diaryPhoto
|
||||
for _, p := range monthPhotos {
|
||||
if p.Date.Day() == dayNum {
|
||||
photos = append(photos, p)
|
||||
}
|
||||
}
|
||||
|
||||
sections = append(sections, diaryDaySection{
|
||||
Heading: heading,
|
||||
URL: dayURL,
|
||||
Content: content,
|
||||
Photos: photos,
|
||||
})
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := diaryMonthTmpl.Execute(&buf, diaryMonthData{Days: sections}); err != nil {
|
||||
log.Printf("diary month template: %v", err)
|
||||
return ""
|
||||
}
|
||||
return template.HTML(buf.String())
|
||||
}
|
||||
|
||||
// renderDiaryDay renders the photo grid for a single day, sourcing photos
|
||||
// from the grandparent year folder.
|
||||
func (h *handler) renderDiaryDay(fsPath, urlPath string) template.HTML {
|
||||
monthFsPath := filepath.Dir(fsPath)
|
||||
yearFsPath := filepath.Dir(monthFsPath)
|
||||
yearURLPath := parentURL(parentURL(urlPath))
|
||||
|
||||
year, err := strconv.Atoi(filepath.Base(yearFsPath))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
monthNum, err := strconv.Atoi(filepath.Base(monthFsPath))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
dayNum, err := strconv.Atoi(filepath.Base(fsPath))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
allPhotos := yearPhotos(yearFsPath, yearURLPath)
|
||||
var photos []diaryPhoto
|
||||
for _, p := range allPhotos {
|
||||
if p.Date.Year() == year && int(p.Date.Month()) == monthNum && p.Date.Day() == dayNum {
|
||||
photos = append(photos, p)
|
||||
}
|
||||
}
|
||||
|
||||
if len(photos) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := diaryDayTmpl.Execute(&buf, diaryDayData{Photos: photos}); err != nil {
|
||||
log.Printf("diary day template: %v", err)
|
||||
return ""
|
||||
}
|
||||
return template.HTML(buf.String())
|
||||
}
|
||||
|
||||
func listEntries(fsPath, urlPath string) []entry {
|
||||
entries, err := os.ReadDir(fsPath)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user