Add conflict resolution diff UI and app branding

This commit is contained in:
2026-05-13 16:25:28 -04:00
parent 780ff3a02c
commit d359baa010
83 changed files with 4523 additions and 3735 deletions

View File

@@ -8,6 +8,7 @@ import (
"fmt"
"html/template"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
@@ -17,7 +18,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/tim/md-hub-secure/apps/server/internal/docs"
"github.com/tim/cairnquire/apps/server/internal/docs"
)
type layoutData struct {
@@ -42,6 +43,16 @@ type documentData struct {
Breadcrumbs []breadcrumb
}
type documentEditData struct {
Browser browserData
Title string
Path string
Tags []string
Hash string
Source string
Breadcrumbs []breadcrumb
}
type breadcrumb struct {
Name string
URL string
@@ -86,7 +97,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
activeFolder := strings.Trim(r.URL.Query().Get("folder"), "/")
s.renderTemplate(w, http.StatusOK, "index.gohtml", layoutData{
Title: "MD Hub Secure",
Title: "Cairnquire",
WebEnabled: s.webEnabled,
BodyClass: "page-index",
BodyTemplate: "index_content",
@@ -126,9 +137,47 @@ func (s *Server) handleDocsIndex(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) {
pagePath := chi.URLParam(r, "*")
if editPath, ok := trimEditSuffix(pagePath); ok {
s.renderDocumentEditor(w, r, editPath)
return
}
s.renderDocumentPage(w, r, pagePath)
}
func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pagePath string) {
page, err := s.documents.LoadSourcePage(r.Context(), pagePath)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
s.renderError(w, r, http.StatusNotFound, "document not found")
return
}
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
items, err := s.documents.ListDocuments(r.Context())
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
s.renderTemplate(w, http.StatusOK, "document_edit.gohtml", layoutData{
Title: "Edit: " + page.Title,
WebEnabled: s.webEnabled,
BodyClass: "page-document-edit",
BodyTemplate: "document_edit_content",
Data: documentEditData{
Browser: buildBrowser(items, page.Path),
Title: page.Title,
Path: page.Path,
Tags: page.Tags,
Hash: page.Hash,
Source: page.Content,
Breadcrumbs: buildBreadcrumbs(page.Path),
},
})
}
func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, pagePath string) {
page, err := s.documents.LoadPage(r.Context(), pagePath)
if err != nil {
@@ -184,6 +233,20 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
})
}
func (s *Server) handleServiceWorker(w http.ResponseWriter, r *http.Request) {
content, err := fs.ReadFile(assets, "static/sw.js")
if err != nil {
http.Error(w, "service worker unavailable", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Service-Worker-Allowed", "/")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(content)
}
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" {
@@ -340,6 +403,52 @@ func (s *Server) handleDocuments(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"documents": results})
}
func (s *Server) handleDocumentSave(w http.ResponseWriter, r *http.Request) {
pagePath := chi.URLParam(r, "*")
if pagePath == "" {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
return
}
var req struct {
Content string `json:"content"`
BaseHash string `json:"baseHash"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
page, err := s.documents.SaveSourcePageWithBaseHash(r.Context(), pagePath, req.Content, req.BaseHash)
if err != nil {
var conflict *docs.DocumentConflictError
if errors.As(err, &conflict) {
writeJSONWithStatus(w, http.StatusConflict, map[string]any{
"error": "document conflict",
"status": "conflict",
"path": conflict.Path,
"baseHash": conflict.BaseHash,
"currentHash": conflict.CurrentHash,
"currentContent": conflict.CurrentContent,
})
return
}
if errors.Is(err, sql.ErrNoRows) {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSONWithStatus(w, http.StatusOK, map[string]any{
"status": "saved",
"path": page.Path,
"title": page.Title,
"hash": page.Hash,
})
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
writeJSONWithStatus(w, status, payload)
}
@@ -350,6 +459,16 @@ func writeJSONWithStatus(w http.ResponseWriter, status int, payload any) {
_ = json.NewEncoder(w).Encode(payload)
}
func trimEditSuffix(path string) (string, bool) {
if path == "edit" {
return "", true
}
if !strings.HasSuffix(path, "/edit") {
return "", false
}
return strings.TrimSuffix(path, "/edit"), true
}
func buildBrowser(records []docs.DocumentRecord, activePath string) browserData {
paths := make([]string, 0, len(records))
titleByPath := make(map[string]string, len(records))