feat: miller column browser, offline cache, search, breadcrumbs

- Dynamic miller column sizing with flexbox layout
- Root column (160px), middle slices (48px), active column (flex)
- Auto-scroll browser to rightmost column on navigation
- Offline indicator UI and IndexedDB cache integration
- /api/documents endpoint for client-side document caching
- Breadcrumb navigation in document content area
- FTS5 search with SQLite virtual table
- Client-side Mermaid and KaTeX rendering via CDN
- Deep sample content (advanced-topics/raft-deep-dive)
- Border removal (only search button keeps radius)
- Full viewport layout with proper borders between sidebar/content
This commit is contained in:
2026-04-30 11:55:03 -04:00
parent e45eeeb600
commit 780ff3a02c
40 changed files with 2986 additions and 190 deletions

View File

@@ -33,12 +33,18 @@ type indexData struct {
}
type documentData struct {
Browser browserData
Title string
Path string
Tags []string
Hash string
HTML template.HTML
Browser browserData
Title string
Path string
Tags []string
Hash string
HTML template.HTML
Breadcrumbs []breadcrumb
}
type breadcrumb struct {
Name string
URL string
}
type errorData struct {
@@ -66,6 +72,12 @@ type browserItem struct {
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query != "" {
s.handleSearchPage(w, r, query)
return
}
items, err := s.documents.ListDocuments(r.Context())
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
@@ -84,6 +96,30 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
})
}
func (s *Server) handleSearchPage(w http.ResponseWriter, r *http.Request, query string) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
results, err := s.repository.SearchDocuments(ctx, query)
if err != nil {
s.logger.Warn("search failed", "error", err)
}
s.renderTemplate(w, http.StatusOK, "search.gohtml", layoutData{
Title: "Search: " + query,
WebEnabled: s.webEnabled,
BodyClass: "page-search",
BodyTemplate: "search_content",
Data: struct {
Query string
Results []docs.SearchResult
}{
Query: query,
Results: results,
},
})
}
func (s *Server) handleDocsIndex(w http.ResponseWriter, r *http.Request) {
s.renderDocumentPage(w, r, "")
}
@@ -116,12 +152,13 @@ func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, page
BodyClass: "page-document",
BodyTemplate: "document_content",
Data: documentData{
Browser: buildBrowser(items, page.Path),
Title: page.Title,
Path: page.Path,
Tags: page.Tags,
Hash: page.Hash,
HTML: template.HTML(page.HTML),
Browser: buildBrowser(items, page.Path),
Title: page.Title,
Path: page.Path,
Tags: page.Tags,
Hash: page.Hash,
HTML: template.HTML(page.HTML),
Breadcrumbs: buildBreadcrumbs(page.Path),
},
})
}
@@ -147,6 +184,26 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
})
}
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" {
writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
results, err := s.repository.SearchDocuments(ctx, query)
if err != nil {
s.logger.Warn("search failed", "error", err)
writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"results": results})
}
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(32 << 20); err != nil {
s.renderError(w, r, http.StatusBadRequest, "invalid multipart upload")
@@ -264,6 +321,25 @@ func sanitizeFilename(name string) string {
return strings.ReplaceAll(name, "\"", "")
}
func (s *Server) handleDocuments(w http.ResponseWriter, r *http.Request) {
records, err := s.documents.ListDocuments(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
results := make([]map[string]any, 0, len(records))
for _, record := range records {
results = append(results, map[string]any{
"path": record.Path,
"title": record.Title,
"hash": record.CurrentHash,
})
}
writeJSON(w, http.StatusOK, map[string]any{"documents": results})
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
writeJSONWithStatus(w, status, payload)
}
@@ -424,3 +500,32 @@ func shouldHideIndexFile(path string, prefix string) bool {
folder := strings.TrimSuffix(path, "/index.md")
return folder == prefix
}
func buildBreadcrumbs(path string) []breadcrumb {
crumbs := []breadcrumb{
{Name: "Content", URL: "/"},
}
if path == "" {
return crumbs
}
cleanPath := strings.TrimSuffix(path, "/index.md")
cleanPath = strings.TrimSuffix(cleanPath, ".md")
parts := strings.Split(cleanPath, "/")
currentPath := ""
for _, part := range parts {
if part == "" {
continue
}
if currentPath == "" {
currentPath = part
} else {
currentPath = currentPath + "/" + part
}
crumbs = append(crumbs, breadcrumb{
Name: part,
URL: "/docs/" + currentPath,
})
}
return crumbs
}