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

@@ -6,7 +6,7 @@ import (
"strings"
"time"
"github.com/tim/md-hub-secure/apps/server/internal/docs"
"github.com/tim/cairnquire/apps/server/internal/docs"
)
type adminUserInput struct {

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))

View File

@@ -4,7 +4,7 @@ import (
"testing"
"time"
"github.com/tim/md-hub-secure/apps/server/internal/docs"
"github.com/tim/cairnquire/apps/server/internal/docs"
)
func TestBuildBrowserUsesFolderIndex(t *testing.T) {
@@ -41,3 +41,25 @@ func TestBuildBrowserUsesFolderIndex(t *testing.T) {
t.Fatalf("folder index file should be represented by the folder, not repeated as a child file")
}
}
func TestTrimEditSuffix(t *testing.T) {
tests := []struct {
name string
input string
wantPath string
wantOK bool
}{
{name: "nested path", input: "guide/setup/edit", wantPath: "guide/setup", wantOK: true},
{name: "root edit sentinel", input: "edit", wantPath: "", wantOK: true},
{name: "document route", input: "guide/setup", wantPath: "", wantOK: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotPath, gotOK := trimEditSuffix(tt.input)
if gotPath != tt.wantPath || gotOK != tt.wantOK {
t.Fatalf("trimEditSuffix(%q) = (%q, %t), want (%q, %t)", tt.input, gotPath, gotOK, tt.wantPath, tt.wantOK)
}
})
}
}

View File

@@ -12,10 +12,11 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/tim/md-hub-secure/apps/server/internal/config"
"github.com/tim/md-hub-secure/apps/server/internal/docs"
"github.com/tim/md-hub-secure/apps/server/internal/realtime"
"github.com/tim/md-hub-secure/apps/server/internal/store"
"github.com/tim/cairnquire/apps/server/internal/config"
"github.com/tim/cairnquire/apps/server/internal/docs"
"github.com/tim/cairnquire/apps/server/internal/realtime"
"github.com/tim/cairnquire/apps/server/internal/store"
"github.com/tim/cairnquire/apps/server/internal/sync"
)
//go:embed templates/*.gohtml static/*
@@ -28,6 +29,8 @@ type Dependencies struct {
Repository *docs.Repository
ContentStore *store.ContentStore
Hub *realtime.Hub
SyncService *sync.Service
SyncRepo *sync.Repository
}
type Server struct {
@@ -37,6 +40,8 @@ type Server struct {
repository *docs.Repository
contentStore *store.ContentStore
hub *realtime.Hub
syncService *sync.Service
syncRepo *sync.Repository
templates *template.Template
webEnabled bool
}
@@ -61,6 +66,8 @@ func New(deps Dependencies) (http.Handler, error) {
repository: deps.Repository,
contentStore: deps.ContentStore,
hub: deps.Hub,
syncService: deps.SyncService,
syncRepo: deps.SyncRepo,
templates: templates,
}
@@ -75,9 +82,11 @@ func New(deps Dependencies) (http.Handler, error) {
router.Use(server.timeoutExceptWebSocket(30 * time.Second))
router.Use(server.requestLogger)
router.Use(server.securityHeaders)
router.Use(server.apiKeyMiddleware)
router.Get("/", server.handleIndex)
router.Get("/health", server.handleHealth)
router.Get("/sw.js", server.handleServiceWorker)
router.Get("/ws", server.handleWebSocket)
router.Post("/api/admin/login", server.handleAdminLogin)
router.Get("/api/admin/users", server.handleAdminUsers)
@@ -87,9 +96,17 @@ func New(deps Dependencies) (http.Handler, error) {
router.Get("/docs", server.handleDocsIndex)
router.Get("/docs/*", server.handleDocument)
router.Get("/api/documents", server.handleDocuments)
router.Post("/api/documents/*", server.handleDocumentSave)
router.Get("/api/search", server.handleSearch)
router.Post("/api/uploads", server.handleUpload)
router.Get("/attachments/{hash}", server.handleAttachment)
// Sync protocol endpoints
router.Post("/api/sync/init", server.handleSyncInit)
router.Post("/api/sync/delta", server.handleSyncDelta)
router.Post("/api/sync/resolve", server.handleSyncResolve)
router.Get("/api/content/{hash}", server.handleContentFetch)
router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(mustSub("static"))))
if server.webEnabled {

View File

@@ -0,0 +1,29 @@
package httpserver
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestHandleServiceWorkerServesJavaScript(t *testing.T) {
server := &Server{}
req := httptest.NewRequest(http.MethodGet, "/sw.js", nil)
recorder := httptest.NewRecorder()
server.handleServiceWorker(recorder, req)
response := recorder.Result()
if response.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusOK)
}
if got := response.Header.Get("Content-Type"); got != "application/javascript; charset=utf-8" {
t.Fatalf("Content-Type = %q, want application/javascript; charset=utf-8", got)
}
if got := response.Header.Get("Service-Worker-Allowed"); got != "/" {
t.Fatalf("Service-Worker-Allowed = %q, want /", got)
}
if recorder.Body.Len() == 0 {
t.Fatal("expected non-empty service worker body")
}
}

View File

@@ -1,8 +1,10 @@
(function () {
const DB_NAME = "md-hub-cache";
const DB_VERSION = 1;
const DB_VERSION = 3;
const STORE_DOCUMENTS = "documents";
const STORE_CONTENT = "content";
const STORE_PAGES = "pages";
const STORE_PENDING_EDITS = "pending_edits";
function openDB() {
return new Promise(function (resolve, reject) {
@@ -21,10 +23,22 @@
if (!db.objectStoreNames.contains(STORE_CONTENT)) {
db.createObjectStore(STORE_CONTENT, { keyPath: "path" });
}
if (!db.objectStoreNames.contains(STORE_PAGES)) {
db.createObjectStore(STORE_PAGES, { keyPath: "path" });
}
if (!db.objectStoreNames.contains(STORE_PENDING_EDITS)) {
db.createObjectStore(STORE_PENDING_EDITS, { keyPath: "path" });
}
};
});
}
function normalizePath(path) {
if (!path) return "/";
if (path === "/") return path;
return path.replace(/\/$/, "") || "/";
}
function cacheDocuments(documents) {
return openDB().then(function (db) {
return new Promise(function (resolve, reject) {
@@ -91,10 +105,158 @@
});
}
function cachePage(path, html, title) {
const normalizedPath = normalizePath(path);
return openDB().then(function (db) {
return new Promise(function (resolve, reject) {
const tx = db.transaction(STORE_PAGES, "readwrite");
const store = tx.objectStore(STORE_PAGES);
store.put({
path: normalizedPath,
html: html,
title: title,
cachedAt: Date.now(),
});
tx.oncomplete = function () {
resolve();
};
tx.onerror = function () {
reject(tx.error);
};
});
});
}
function getCachedPage(path) {
const normalizedPath = normalizePath(path);
return openDB().then(function (db) {
return new Promise(function (resolve, reject) {
const tx = db.transaction(STORE_PAGES, "readonly");
const store = tx.objectStore(STORE_PAGES);
const request = store.get(normalizedPath);
request.onsuccess = function () {
resolve(request.result);
};
request.onerror = function () {
reject(request.error);
};
});
});
}
function putPendingEdit(path, content, baseHash) {
const normalizedPath = normalizePath(path);
return openDB().then(function (db) {
return new Promise(function (resolve, reject) {
const tx = db.transaction(STORE_PENDING_EDITS, "readwrite");
const store = tx.objectStore(STORE_PENDING_EDITS);
store.put({
path: normalizedPath,
content: content,
baseHash: baseHash || "",
conflict: null,
retries: 0,
updatedAt: Date.now(),
});
tx.oncomplete = function () {
resolve();
};
tx.onerror = function () {
reject(tx.error);
};
});
});
}
function markPendingEditConflict(path, conflict) {
const normalizedPath = normalizePath(path);
return openDB().then(function (db) {
return new Promise(function (resolve, reject) {
const tx = db.transaction(STORE_PENDING_EDITS, "readwrite");
const store = tx.objectStore(STORE_PENDING_EDITS);
const request = store.get(normalizedPath);
request.onsuccess = function () {
const record = request.result;
if (!record) {
resolve();
return;
}
record.conflict = conflict;
record.updatedAt = Date.now();
store.put(record);
};
tx.oncomplete = function () {
resolve();
};
tx.onerror = function () {
reject(tx.error);
};
});
});
}
function getPendingEdits() {
return openDB().then(function (db) {
return new Promise(function (resolve, reject) {
const tx = db.transaction(STORE_PENDING_EDITS, "readonly");
const store = tx.objectStore(STORE_PENDING_EDITS);
const request = store.getAll();
request.onsuccess = function () {
resolve(request.result || []);
};
request.onerror = function () {
reject(request.error);
};
});
});
}
function getPendingEdit(path) {
const normalizedPath = normalizePath(path);
return openDB().then(function (db) {
return new Promise(function (resolve, reject) {
const tx = db.transaction(STORE_PENDING_EDITS, "readonly");
const store = tx.objectStore(STORE_PENDING_EDITS);
const request = store.get(normalizedPath);
request.onsuccess = function () {
resolve(request.result);
};
request.onerror = function () {
reject(request.error);
};
});
});
}
function deletePendingEdit(path) {
const normalizedPath = normalizePath(path);
return openDB().then(function (db) {
return new Promise(function (resolve, reject) {
const tx = db.transaction(STORE_PENDING_EDITS, "readwrite");
const store = tx.objectStore(STORE_PENDING_EDITS);
store.delete(normalizedPath);
tx.oncomplete = function () {
resolve();
};
tx.onerror = function () {
reject(tx.error);
};
});
});
}
window.MDHubCache = {
cacheDocuments: cacheDocuments,
cacheContent: cacheContent,
cachePage: cachePage,
putPendingEdit: putPendingEdit,
markPendingEditConflict: markPendingEditConflict,
getPendingEdits: getPendingEdits,
getPendingEdit: getPendingEdit,
deletePendingEdit: deletePendingEdit,
getCachedDocuments: getCachedDocuments,
getCachedContent: getCachedContent,
getCachedPage: getCachedPage,
normalizePath: normalizePath,
};
})();

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 KiB

View File

@@ -0,0 +1,579 @@
(function () {
var form = document.querySelector("[data-editor-form]");
var textarea = document.querySelector("[data-document-editor]");
var statusNodes = document.querySelectorAll("[data-sync-status], [data-sync-status-secondary]");
var hashEl = document.querySelector("[data-editor-hash]");
var documentShell = document.querySelector("[data-document-path][data-document-hash]");
var conflictNotice = document.querySelector("[data-conflict-notice]");
var conflictHash = document.querySelector("[data-conflict-hash]");
var conflictStatus = document.querySelector("[data-conflict-status]");
var conflictDiff = document.querySelector("[data-conflict-diff]");
var conflictApply = document.querySelector("[data-conflict-apply]");
var conflictDismiss = document.querySelector("[data-conflict-dismiss]");
var conflictResolution = document.querySelector("[data-conflict-resolution]");
var conflictResolutionMount = document.querySelector("[data-conflict-monaco-mount]");
var conflictUseServer = document.querySelector("[data-conflict-use='server']");
var conflictUseLocal = document.querySelector("[data-conflict-use='local']");
var conflictUseBoth = document.querySelector("[data-conflict-use='both']");
var monacoContainer = document.querySelector("[data-monaco-mount]");
var monacoLoaded = false;
var monacoReadyPromise = null;
var monacoDarkTheme = "cairnquire-dark";
var monacoLightTheme = "cairnquire-light";
if (!form || !textarea || statusNodes.length === 0) {
return;
}
var path = form.getAttribute("data-document-path");
var baseHash = documentShell ? documentShell.getAttribute("data-document-hash") : "";
var saveTimer = null;
var lastSavedValue = textarea.value;
var editor = null;
var currentConflict = null;
var currentConflictLocalContent = "";
var resolvedConflictContent = "";
var conflictDiffView = null;
var resolutionEditor = null;
var suppressResolutionEvents = false;
function setStatus(nextStatus) {
statusNodes.forEach(function (node) {
node.textContent = nextStatus;
node.dataset.state = nextStatus.toLowerCase();
});
}
function getEditorValue() {
return editor ? editor.getValue() : textarea.value;
}
function setEditorValue(value) {
textarea.value = value;
if (editor && editor.getValue() !== value) {
editor.setValue(value);
}
}
function getResolutionValue() {
return resolutionEditor ? resolutionEditor.getValue() : resolvedConflictContent;
}
function setResolutionEditorValue(value) {
resolvedConflictContent = value;
if (conflictResolution) {
conflictResolution.value = value;
}
if (resolutionEditor && resolutionEditor.getValue() !== value) {
suppressResolutionEvents = true;
resolutionEditor.setValue(value);
suppressResolutionEvents = false;
}
}
function ensureTrailingNewline(value) {
return value.endsWith("\n") ? value : value + "\n";
}
function buildCombinedResolution(serverContent, localContent) {
return ensureTrailingNewline(serverContent) + "\n" + ensureTrailingNewline(localContent);
}
function setConflictMessage(message) {
if (conflictStatus) {
conflictStatus.textContent = message;
}
}
function setResolutionContent(content, message) {
setResolutionEditorValue(content);
if (conflictApply) {
conflictApply.disabled = false;
}
if (message) {
setConflictMessage(message);
}
}
async function loadDiffs() {
var modules = await Promise.all([
import("https://cdn.jsdelivr.net/npm/@pierre/diffs@1.1.22/+esm"),
import("https://cdn.jsdelivr.net/npm/@pierre/diffs@1.1.22/dist/style.js"),
]);
return {
renderer: modules[0],
stylesheet: modules[1].default || "",
};
}
function styleDiffContainer(diffContainer) {
diffContainer.style.setProperty("--diffs-light-bg", "#fcfaf5");
diffContainer.style.setProperty("--diffs-light", "#1c2430");
diffContainer.style.setProperty("--diffs-dark-bg", "#1e1e1e");
diffContainer.style.setProperty("--diffs-dark", "#e8ecf0");
diffContainer.style.setProperty("--diffs-addition-color", "#3f8f59");
diffContainer.style.setProperty("--diffs-deletion-color", "#c8553d");
diffContainer.style.setProperty("--diffs-modified-color", "#c67a2a");
diffContainer.style.setProperty("--diffs-font-family", "'Iosevka', 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace");
diffContainer.style.setProperty("--diffs-header-font-family", "'Inter', system-ui, -apple-system, BlinkMacSystemFont, sans-serif");
}
function injectDiffStyles(diffContainer, stylesheet) {
var root = diffContainer.shadowRoot;
if (!root || !stylesheet || root.querySelector("[data-cairnquire-diffs-style]")) {
return;
}
var style = document.createElement("style");
style.setAttribute("data-cairnquire-diffs-style", "");
style.textContent = stylesheet;
root.prepend(style);
}
function renderConflictResolver(conflict, localContent) {
if (!conflictDiff) {
return;
}
if (conflictDiffView) {
conflictDiffView.cleanUp();
conflictDiffView = null;
}
conflictDiff.replaceChildren();
currentConflictLocalContent = localContent;
setResolutionEditorValue(localContent);
if (conflictApply) {
conflictApply.disabled = false;
}
ensureResolutionEditor();
setConflictMessage("Loading conflict resolver...");
loadDiffs().then(function (diffsModule) {
var diffs = diffsModule.renderer;
var diffContainer = document.createElement("div");
styleDiffContainer(diffContainer);
conflictDiff.appendChild(diffContainer);
var serverFile = {
name: path,
contents: conflict.currentContent || "",
lang: "markdown",
cacheKey: "server:" + (conflict.currentHash || ""),
};
var localFile = {
name: path,
contents: localContent,
lang: "markdown",
cacheKey: "local:" + (conflict.baseHash || "") + ":" + localContent.length,
};
conflictDiffView = new diffs.FileDiff({
disableFileHeader: false,
diffStyle: "unified",
diffIndicators: "classic",
lineDiffType: "word",
overflow: "scroll",
parseDiffOptions: { context: 4 },
});
conflictDiffView.render({
oldFile: serverFile,
newFile: localFile,
fileContainer: diffContainer,
});
injectDiffStyles(diffContainer, diffsModule.stylesheet);
setConflictMessage("Red deletions are from the server copy. Green additions are from your queued edit.");
}).catch(function () {
setConflictMessage("Could not load the diff renderer. Your edit is still queued locally.");
});
}
function showConflict(conflict, localContent) {
currentConflict = conflict;
setStatus("Conflict");
form.hidden = true;
if (conflictHash && conflict && conflict.currentHash) {
conflictHash.textContent = conflict.currentHash;
}
if (conflictNotice) {
conflictNotice.hidden = false;
}
renderConflictResolver(conflict, localContent || getEditorValue());
}
function hideConflict() {
form.hidden = false;
if (conflictNotice) {
conflictNotice.hidden = true;
}
if (conflictDiff) {
conflictDiff.replaceChildren();
}
if (conflictDiffView) {
conflictDiffView.cleanUp();
conflictDiffView = null;
}
if (conflictApply) {
conflictApply.disabled = true;
}
setResolutionEditorValue("");
currentConflict = null;
currentConflictLocalContent = "";
}
async function saveNow() {
var value = getEditorValue();
if (value === lastSavedValue) {
setStatus("Saved");
return;
}
hideConflict();
setStatus("Saving");
if (!window.MDHubSync) {
setStatus("Queued");
return;
}
var result = await window.MDHubSync.saveDocument(path, value, baseHash);
if (result.status === "saved") {
lastSavedValue = value;
if (hashEl && result.result && result.result.hash) {
hashEl.textContent = result.result.hash;
baseHash = result.result.hash;
if (documentShell) {
documentShell.setAttribute("data-document-hash", result.result.hash);
}
}
setStatus("Saved");
return;
}
if (result.status === "conflict") {
showConflict(result.conflict, value);
return;
}
setStatus("Queued");
}
async function applyResolvedConflict() {
if (!window.MDHubSync || !currentConflict) {
return;
}
var resolutionContent = getResolutionValue();
resolvedConflictContent = resolutionContent;
setConflictMessage("Saving resolved document...");
if (conflictApply) {
conflictApply.disabled = true;
}
var result = await window.MDHubSync.saveDocument(path, resolutionContent, currentConflict.currentHash);
if (result.status === "saved") {
setEditorValue(resolutionContent);
lastSavedValue = resolutionContent;
if (hashEl && result.result && result.result.hash) {
hashEl.textContent = result.result.hash;
baseHash = result.result.hash;
if (documentShell) {
documentShell.setAttribute("data-document-hash", result.result.hash);
}
}
hideConflict();
setStatus("Saved");
return;
}
if (result.status === "conflict") {
showConflict(result.conflict, resolutionContent);
return;
}
setStatus("Queued");
setConflictMessage("Resolved document is queued and will retry when the server is reachable.");
}
function restorePendingConflict() {
if (!window.MDHubCache || !window.MDHubCache.getPendingEdit) {
return;
}
var normalizedPath = window.MDHubCache.normalizePath("/docs/" + path.replace(/^\/+/, ""));
window.MDHubCache.getPendingEdit(normalizedPath).then(function (pending) {
if (pending && pending.conflict) {
showConflict(pending.conflict, pending.content || getEditorValue());
}
}).catch(function () {});
}
function scheduleSave() {
setStatus("Unsaved");
if (saveTimer) {
clearTimeout(saveTimer);
}
saveTimer = setTimeout(function () {
saveNow().catch(function () {
setStatus("Queued");
});
}, 2000);
}
function loadMonaco() {
if (monacoReadyPromise) {
return monacoReadyPromise;
}
if (monacoLoaded && window.monaco) {
monacoReadyPromise = Promise.resolve();
return monacoReadyPromise;
}
monacoLoaded = true;
var isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
monacoReadyPromise = new Promise(function (resolve) {
var script = document.createElement("script");
script.src = "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js";
script.onload = function () {
require.config({
paths: { vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs" },
});
require(["vs/editor/editor.main"], function () {
monaco.editor.defineTheme(monacoDarkTheme, {
base: "vs-dark",
inherit: true,
rules: [
{ token: "comment", foreground: "8B9DAF", fontStyle: "italic" },
{ token: "keyword", foreground: "E8943A" },
{ token: "string", foreground: "C9A96E" },
{ token: "number", foreground: "C9A96E" },
{ token: "type", foreground: "E8943A" },
{ token: "tag", foreground: "E8943A" },
{ token: "attribute.name", foreground: "D4A05A" },
{ token: "attribute.value", foreground: "C9A96E" },
{ token: "delimiter", foreground: "B0B8C4" },
{ token: "variable", foreground: "D4D8DE" },
],
colors: {
"editor.background": "#1E1E1E",
"editor.foreground": "#E8ECF0",
"editor.lineHighlightBackground": "#282828",
"editor.selectionBackground": "#3A3A3A",
"editorLineNumber.foreground": "#555555",
"editorLineNumber.activeForeground": "#E8943A",
"editor.inactiveSelectionBackground": "#333333",
"editorCursor.foreground": "#E8943A",
"editorIndentGuide.background": "#2A2A2A",
"editorIndentGuide.activeBackground": "#3A3A3A",
"editorWhitespace.foreground": "#2A2A2A",
"editorGutter.background": "#1E1E1E",
"editorOverviewRuler.border": "#1E1E1E",
"scrollbarSlider.background": "#3A3A3A88",
"scrollbarSlider.hoverBackground": "#4A4A4A88",
"scrollbarSlider.activeBackground": "#5A5A5A88",
"minimap.background": "#1E1E1E",
},
});
monaco.editor.defineTheme(monacoLightTheme, {
base: "vs",
inherit: true,
rules: [
{ token: "comment", foreground: "6A7B8C", fontStyle: "italic" },
{ token: "keyword", foreground: "C67A2A" },
{ token: "string", foreground: "8A6D3B" },
{ token: "number", foreground: "8A6D3B" },
{ token: "type", foreground: "C67A2A" },
{ token: "tag", foreground: "C67A2A" },
{ token: "attribute.name", foreground: "A87D3A" },
{ token: "attribute.value", foreground: "8A6D3B" },
],
colors: {
"editor.background": "#FCFAF5",
"editor.foreground": "#1C2430",
"editor.lineHighlightBackground": "#F0EDE5",
"editor.selectionBackground": "#E8943A33",
"editorLineNumber.foreground": "#AAAAAA",
"editorLineNumber.activeForeground": "#C67A2A",
"editorCursor.foreground": "#C67A2A",
"editorIndentGuide.background": "#E8E5DD",
"editorGutter.background": "#FCFAF5",
},
});
if (monacoContainer) {
editor = monaco.editor.create(monacoContainer, {
value: textarea.value,
language: "markdown",
theme: isDark ? monacoDarkTheme : monacoLightTheme,
fontFamily: "'Iosevka', 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace",
fontSize: 14,
lineHeight: 22,
minimap: { enabled: false },
wordWrap: "on",
scrollBeyondLastLine: false,
padding: { top: 12 },
renderLineHighlight: "line",
smoothScrolling: true,
cursorBlinking: "smooth",
cursorSmoothCaretAnimation: "on",
bracketPairColorization: { enabled: true },
automaticLayout: true,
});
textarea.hidden = true;
monacoContainer.classList.add("monaco-mounted");
editor.onDidChangeModelContent(function () {
textarea.value = editor.getValue();
scheduleSave();
});
if (window.MDHubSync) {
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, function () {
saveNow().catch(function () {
setStatus("Queued");
});
});
}
var darkMQ = window.matchMedia("(prefers-color-scheme: dark)");
darkMQ.addEventListener("change", function (e) {
monaco.editor.setTheme(e.matches ? monacoDarkTheme : monacoLightTheme);
});
editor.focus();
}
resolve();
});
};
document.head.appendChild(script);
});
return monacoReadyPromise;
}
function ensureResolutionEditor() {
if (!conflictResolution || !conflictResolutionMount) {
return;
}
loadMonaco().then(function () {
if (!window.monaco || resolutionEditor) {
return;
}
var isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
resolutionEditor = monaco.editor.create(conflictResolutionMount, {
value: conflictResolution.value || resolvedConflictContent,
language: "markdown",
theme: isDark ? monacoDarkTheme : monacoLightTheme,
fontFamily: "'Iosevka', 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace",
fontSize: 14,
lineHeight: 22,
minimap: { enabled: false },
wordWrap: "on",
scrollBeyondLastLine: false,
padding: { top: 12 },
renderLineHighlight: "line",
smoothScrolling: true,
cursorBlinking: "smooth",
cursorSmoothCaretAnimation: "on",
bracketPairColorization: { enabled: true },
automaticLayout: true,
});
conflictResolution.hidden = true;
conflictResolutionMount.classList.add("monaco-mounted");
resolutionEditor.onDidChangeModelContent(function () {
if (suppressResolutionEvents) {
return;
}
resolvedConflictContent = resolutionEditor.getValue();
conflictResolution.value = resolvedConflictContent;
if (conflictApply) {
conflictApply.disabled = false;
}
setConflictMessage("Manual resolution updated. Apply it to save the resolved document.");
});
resolutionEditor.focus();
}).catch(function () {
conflictResolution.hidden = false;
});
}
if (monacoContainer) {
loadMonaco().catch(function () {
textarea.addEventListener("input", scheduleSave);
});
} else {
textarea.addEventListener("input", scheduleSave);
}
form.addEventListener("submit", function (event) {
event.preventDefault();
saveNow().catch(function () {
setStatus("Queued");
});
});
if (conflictApply) {
conflictApply.addEventListener("click", function () {
applyResolvedConflict().catch(function () {
setStatus("Queued");
setConflictMessage("Could not save the resolved document. It remains queued locally.");
});
});
}
if (conflictDismiss) {
conflictDismiss.addEventListener("click", function () {
hideConflict();
});
}
if (conflictResolution) {
conflictResolution.addEventListener("input", function () {
resolvedConflictContent = conflictResolution.value;
if (conflictApply) {
conflictApply.disabled = false;
}
setConflictMessage("Manual resolution updated. Apply it to save the resolved document.");
});
}
if (conflictUseServer) {
conflictUseServer.addEventListener("click", function () {
if (!currentConflict) {
return;
}
setResolutionContent(currentConflict.currentContent || "", "Using the server copy as the resolution. You can edit it before applying.");
});
}
if (conflictUseLocal) {
conflictUseLocal.addEventListener("click", function () {
setResolutionContent(currentConflictLocalContent, "Using your queued edit as the resolution. You can edit it before applying.");
});
}
if (conflictUseBoth) {
conflictUseBoth.addEventListener("click", function () {
if (!currentConflict) {
return;
}
setResolutionContent(
buildCombinedResolution(currentConflict.currentContent || "", currentConflictLocalContent),
"Using both versions as the resolution. Edit the combined content before applying."
);
});
}
restorePendingConflict();
document.addEventListener("keydown", function (e) {
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
}
});
})();

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -2,8 +2,10 @@
const notice = document.querySelector("[data-version-notice]");
const reload = document.querySelector("[data-version-reload]");
const offlineNotice = document.querySelector("[data-offline-notice]");
const cachedNotice = document.querySelector("[data-cached-notice]");
const documentShell = document.querySelector("[data-document-path][data-document-hash]");
const browser = document.querySelector(".miller-browser");
const isEditing = Boolean(document.querySelector("[data-editor-form]"));
// Auto-scroll miller browser to show the rightmost (active) column
function scrollBrowserToRight() {
@@ -23,6 +25,120 @@
scrollBrowserToRight();
function initColumnPreviewExpansion() {
if (!browser) {
return;
}
let hoverTimer = null;
let expandedColumn = null;
function isCompressedColumn(column) {
return (
column &&
column.matches(".miller-column:not(:first-child):not(:last-child)")
);
}
function collapseColumn(column) {
if (!column) return;
column.classList.remove("is-preview-expanded");
column.style.removeProperty("--expanded-column-width");
if (expandedColumn === column) {
expandedColumn = null;
}
}
function expandColumn(column) {
if (!isCompressedColumn(column)) return;
if (expandedColumn && expandedColumn !== column) {
collapseColumn(expandedColumn);
}
const expandedWidth = Math.min(Math.max(column.scrollWidth + 12, 192), 384);
column.style.setProperty("--expanded-column-width", expandedWidth + "px");
column.classList.add("is-preview-expanded");
expandedColumn = column;
requestAnimationFrame(function () {
const leftEdge = column.offsetLeft;
const rightEdge = leftEdge + expandedWidth;
let targetLeft = browser.scrollLeft;
if (leftEdge < browser.scrollLeft) {
targetLeft = leftEdge - 8;
} else if (rightEdge > browser.scrollLeft + browser.clientWidth) {
targetLeft = rightEdge - browser.clientWidth + 8;
}
browser.scrollTo({
left: Math.max(0, targetLeft),
behavior: "smooth",
});
});
}
function scheduleExpansion(column) {
if (!isCompressedColumn(column)) return;
clearTimeout(hoverTimer);
hoverTimer = setTimeout(function () {
expandColumn(column);
}, 1000);
}
browser.querySelectorAll(".miller-column").forEach(function (column) {
column.addEventListener("pointerenter", function () {
scheduleExpansion(column);
});
column.addEventListener("mouseenter", function () {
scheduleExpansion(column);
});
column.addEventListener("mouseover", function () {
if (!isCompressedColumn(column)) return;
if (expandedColumn === column) return;
scheduleExpansion(column);
});
column.addEventListener("pointerleave", function () {
clearTimeout(hoverTimer);
collapseColumn(column);
});
column.addEventListener("mouseleave", function () {
clearTimeout(hoverTimer);
collapseColumn(column);
});
});
}
initColumnPreviewExpansion();
function updateCachedUI(isCached) {
if (!cachedNotice) return;
cachedNotice.hidden = !isCached;
}
function cacheCurrentPage() {
if (!window.MDHubCache) return;
const html = document.documentElement.outerHTML;
const title = document.title;
const pagePath = window.MDHubCache.normalizePath(window.location.pathname);
window.MDHubCache.cachePage(pagePath, html, title).catch(function () {});
}
function registerServiceWorker() {
if (!("serviceWorker" in navigator)) return;
window.addEventListener("load", function () {
navigator.serviceWorker.register("/sw.js").catch(function () {});
});
}
registerServiceWorker();
updateCachedUI(Boolean(window.__MDHUB_OFFLINE_CACHED__));
function updateOfflineUI() {
if (!offlineNotice) return;
if (navigator.onLine) {
@@ -54,6 +170,8 @@
}
}
cacheCurrentPage();
// Fetch and cache documents
function fetchAndCacheDocuments() {
if (!window.MDHubCache) return;
@@ -130,6 +248,9 @@
const bodyEl = documentShell.querySelector(".markdown-body");
if (bodyEl) bodyEl.innerHTML = html;
updateCachedUI(true);
cacheCurrentPage();
// Re-render math and mermaid
if (typeof renderMath === "function") renderMath();
if (typeof renderMermaid === "function") renderMermaid();
@@ -185,6 +306,10 @@
const change = payload.data;
if (isEditing && currentPath && change.path === currentPath) {
return;
}
if (currentPath && change.path === currentPath && change.hash !== currentHash) {
if (notice) notice.hidden = false;
return;

View File

@@ -1,5 +1,5 @@
:root {
color-scheme: light;
color-scheme: light dark;
--bg: #fbf7ef;
--panel: rgba(255, 255, 255, 0.82);
--panel-strong: #ffffff;
@@ -15,10 +15,28 @@
font-family: "Charter", "Iowan Old Style", "Palatino Linotype", serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: oklch(0.14 0.012 55);
--panel: oklch(0.18 0.014 55 / 0.85);
--panel-strong: oklch(0.21 0.015 55);
--text: oklch(0.93 0.006 55);
--muted: oklch(0.66 0.018 55);
--accent: oklch(0.705 0.165 55);
--accent-soft: oklch(0.705 0.165 55 / 0.14);
--border: oklch(0.27 0.018 55);
--shadow: 0 16px 48px oklch(0.08 0.02 55 / 0.35);
}
}
* {
box-sizing: border-box;
}
html {
transition: color-scheme 0.4s ease;
}
body {
margin: 0;
min-height: 100vh;
@@ -27,6 +45,18 @@ body {
radial-gradient(circle at top left, rgba(56, 189, 248, 0.28), transparent 34%),
radial-gradient(circle at bottom right, rgba(99, 102, 241, 0.22), transparent 30%),
linear-gradient(180deg, #eef6ff 0%, #dbeafe 100%);
transition:
color 0.35s ease,
background 0.55s ease;
}
@media (prefers-color-scheme: dark) {
body {
background:
radial-gradient(circle at top left, oklch(0.24 0.04 55 / 0.45), transparent 36%),
radial-gradient(circle at bottom right, oklch(0.20 0.035 50 / 0.35), transparent 32%),
linear-gradient(180deg, oklch(0.12 0.012 55) 0%, oklch(0.14 0.012 55) 100%);
}
}
a {
@@ -60,12 +90,20 @@ code {
}
.site-brand {
color: var(--text);
font-size: 1.15rem;
font-weight: 700;
display: inline-flex;
align-items: center;
flex: 0 0 auto;
text-decoration: none;
}
.site-brand img {
display: block;
width: auto;
height: 2.5rem;
max-width: min(12rem, 42vw);
object-fit: contain;
}
.site-nav {
display: flex;
gap: 1.25rem;
@@ -157,6 +195,7 @@ code {
display: flex;
flex-direction: column;
overflow: hidden;
transition: width 180ms ease, min-width 180ms ease, box-shadow 180ms ease;
}
/* Root column - fixed width for top-level navigation */
@@ -170,6 +209,18 @@ code {
width: 48px;
}
.miller-column:not(:first-child):not(:last-child):hover,
.miller-column:not(:first-child):not(:last-child).is-preview-expanded {
width: var(--expanded-column-width, max-content);
min-width: 12rem;
max-width: 24rem;
box-shadow: 8px 0 24px rgba(24, 32, 42, 0.08);
}
.miller-column:not(:first-child):not(:last-child):hover {
transition-delay: 1s;
}
/* Active/last column - grows to fill remaining sidebar space */
.miller-column:last-child {
flex: 1 1 auto;
@@ -207,6 +258,17 @@ code {
vertical-align: bottom;
}
.miller-column:not(:first-child):not(:last-child):hover h2,
.miller-column:not(:first-child):not(:last-child).is-preview-expanded h2 {
padding: 0.7rem 0.8rem;
text-align: left;
}
.miller-column:not(:first-child):not(:last-child):hover h2 .miller-column-title-text,
.miller-column:not(:first-child):not(:last-child).is-preview-expanded h2 .miller-column-title-text {
max-width: min(20rem, calc(var(--expanded-column-width, 14rem) - 2rem));
}
.miller-column ul {
margin: 0;
padding: 0.3rem;
@@ -252,6 +314,28 @@ code {
display: none;
}
.miller-column:not(:first-child):not(:last-child):hover a,
.miller-column:not(:first-child):not(:last-child).is-preview-expanded a {
padding: 0.4rem 0.5rem;
justify-content: space-between;
}
.miller-column:not(:first-child):not(:last-child):hover .browser-item-label,
.miller-column:not(:first-child):not(:last-child).is-preview-expanded .browser-item-label {
justify-content: flex-start;
gap: 0.4rem;
}
.miller-column:not(:first-child):not(:last-child):hover .browser-item-name,
.miller-column:not(:first-child):not(:last-child).is-preview-expanded .browser-item-name {
max-width: min(18rem, calc(var(--expanded-column-width, 14rem) - 3.5rem));
}
.miller-column:not(:first-child):not(:last-child):hover .browser-item-chevron,
.miller-column:not(:first-child):not(:last-child).is-preview-expanded .browser-item-chevron {
display: inline-block;
}
.browser-item-label {
display: inline-flex;
min-width: 0;
@@ -260,69 +344,24 @@ code {
}
.browser-icon {
position: relative;
display: inline-block;
width: 1rem;
height: 0.9rem;
height: 1rem;
flex: 0 0 auto;
}
.browser-icon--folder {
margin-top: 0.1rem;
border: 1px solid rgba(15, 91, 216, 0.24);
border-radius: 0;
background: var(--accent-soft);
}
.browser-icon--folder::before {
position: absolute;
top: -0.22rem;
left: 0.1rem;
width: 0.45rem;
height: 0.25rem;
border: 1px solid rgba(15, 91, 216, 0.24);
border-bottom: 0;
border-radius: 0;
background: var(--accent-soft);
content: "";
color: #0f5bd8;
}
.browser-icon--page {
border: 1px solid var(--border);
border-radius: 0;
background: rgba(255, 255, 255, 0.72);
}
.browser-icon--page::before {
position: absolute;
right: -1px;
top: -1px;
width: 0.3rem;
height: 0.3rem;
border-left: 1px solid var(--border);
border-bottom: 1px solid var(--border);
background: var(--panel);
content: "";
color: #465365;
}
.browser-icon--root {
width: 0.85rem;
height: 0.85rem;
border: 1.5px solid var(--accent);
border-radius: 0;
background: var(--accent-soft);
}
.browser-icon--root::before {
position: absolute;
top: 50%;
left: 50%;
width: 0.3rem;
height: 0.3rem;
border-radius: 0;
background: var(--accent);
transform: translate(-50%, -50%);
content: "";
width: 0.95rem;
height: 0.95rem;
color: var(--accent);
}
.miller-column--root h2 {
@@ -343,6 +382,12 @@ code {
white-space: nowrap;
}
.browser-item-chevron {
width: 0.95rem;
height: 0.95rem;
flex: 0 0 auto;
}
/* Document shell */
.document-shell,
.error-panel,
@@ -447,11 +492,51 @@ code {
}
.document-meta h1 {
margin: 0 0 0.75rem;
margin: 0;
font-size: 1.85rem;
line-height: 1.1;
}
.document-meta-panel {
margin-top: 0.9rem;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.45);
}
.document-meta-panel summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.7rem 0.85rem;
cursor: pointer;
list-style: none;
color: var(--muted);
font: 700 0.78rem/1.2 ui-monospace, SFMono-Regular, monospace;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.document-meta-panel summary::-webkit-details-marker {
display: none;
}
.document-meta-panel summary::after {
content: "+";
margin-left: auto;
color: var(--text);
font-size: 1rem;
letter-spacing: 0;
}
.document-meta-panel[open] summary::after {
content: "-";
}
.document-meta-panel .meta-grid {
padding: 0 0.85rem 0.85rem;
}
.meta-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
@@ -535,6 +620,26 @@ code {
display: none;
}
.cached-notice {
position: fixed;
left: 1rem;
bottom: 4.5rem;
display: flex;
align-items: center;
gap: 0.6rem;
max-width: min(28rem, calc(100vw - 2rem));
padding: 0.6rem 0.85rem;
border: 1px solid rgba(37, 99, 235, 0.25);
border-radius: 0;
background: rgba(239, 246, 255, 0.96);
color: #1d4ed8;
font-size: 0.9rem;
}
.cached-notice[hidden] {
display: none;
}
.offline-icon {
display: inline-block;
width: 0.6rem;
@@ -544,6 +649,15 @@ code {
flex-shrink: 0;
}
.cached-icon {
display: inline-block;
width: 0.6rem;
height: 0.6rem;
border-radius: 0;
background: #2563eb;
flex-shrink: 0;
}
.version-notice[hidden] {
display: none;
}
@@ -564,6 +678,284 @@ code {
cursor: pointer;
}
.document-meta__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.document-meta__header--editor {
align-items: end;
}
.document-edit-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.4rem;
padding: 0 0.9rem;
border: 1px solid var(--border);
color: var(--text);
text-decoration: none;
background: var(--panel);
}
.document-shell--editor {
display: grid;
grid-template-rows: auto auto 1fr;
min-height: 0;
}
.editor-form {
min-height: 0;
position: relative;
}
.editor-form[hidden] {
display: none;
}
[data-monaco-mount] {
display: none;
width: 100%;
min-height: 60vh;
height: 100%;
border: 1px solid var(--border);
overflow: hidden;
}
[data-monaco-mount].monaco-mounted {
display: block;
}
.editor-form textarea {
width: 100%;
min-height: 60vh;
height: 100%;
resize: vertical;
border: 1px solid var(--border);
background: #fbfbf9;
color: var(--text);
padding: 1rem;
font: 400 0.98rem/1.6 ui-monospace, SFMono-Regular, monospace;
}
[data-monaco-mount].monaco-mounted ~ textarea {
display: none;
}
@keyframes monaco-shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
[data-monaco-mount]:not(.monaco-mounted) {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(90deg, var(--panel-strong) 25%, var(--panel) 50%, var(--panel-strong) 75%);
background-size: 200% 100%;
animation: monaco-shimmer 1.5s ease infinite;
color: var(--muted);
font-size: 0.85rem;
}
.editor-status {
display: inline-block;
min-width: 5.5rem;
font-weight: 600;
text-align: right;
}
.editor-status[data-state="queued"] {
color: #92400e;
}
.editor-status[data-state="conflict"] {
color: #b91c1c;
}
.editor-status[data-state="saving"],
.editor-status[data-state="unsaved"] {
color: #1d4ed8;
}
.editor-conflict {
display: grid;
gap: 0.75rem;
margin-top: 0.8rem;
padding: 0.75rem 0.9rem;
border: 1px solid #fecaca;
background: #fff1f2;
color: #7f1d1d;
}
.editor-conflict[hidden] {
display: none;
}
.editor-conflict__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.editor-conflict__header > div:first-child {
display: grid;
gap: 0.2rem;
}
.editor-conflict__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
}
.editor-conflict__actions button {
min-height: 2.1rem;
padding: 0 0.75rem;
border: 1px solid #fecaca;
background: #fff;
color: #7f1d1d;
cursor: pointer;
}
.editor-conflict__actions button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.editor-conflict__choices {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.editor-conflict__choices button {
min-height: 2rem;
padding: 0 0.65rem;
border: 1px solid rgba(127, 29, 29, 0.2);
background: rgba(255, 255, 255, 0.72);
color: #7f1d1d;
cursor: pointer;
font: inherit;
font-size: 0.85rem;
}
.editor-conflict__choices button[data-conflict-use="server"] {
border-color: rgba(185, 28, 28, 0.25);
background: rgba(254, 202, 202, 0.6);
}
.editor-conflict__choices button[data-conflict-use="local"] {
border-color: rgba(22, 101, 52, 0.25);
background: rgba(187, 247, 208, 0.55);
color: #14532d;
}
.editor-conflict p {
margin: 0;
}
.editor-conflict__legend {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
font-size: 0.86rem;
}
.editor-conflict__legend span {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.editor-conflict__swatch {
width: 0.75rem;
height: 0.75rem;
border: 1px solid currentColor;
display: inline-block;
}
.editor-conflict__swatch--server {
background: rgba(200, 85, 61, 0.2);
color: #b91c1c;
}
.editor-conflict__swatch--local {
background: rgba(63, 143, 89, 0.2);
color: #166534;
}
.editor-conflict__diff {
min-height: 16rem;
overflow: auto;
border: 1px solid rgba(127, 29, 29, 0.18);
background: #fff;
}
.editor-conflict code {
word-break: break-all;
}
.editor-conflict__resolution {
display: grid;
gap: 0.4rem;
}
.editor-conflict__resolution label {
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.editor-conflict__resolution textarea {
width: 100%;
min-height: 16rem;
resize: vertical;
border: 1px solid rgba(127, 29, 29, 0.18);
background: #fff;
color: #1c2430;
font-family: "Iosevka", "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
font-size: 0.9rem;
line-height: 1.55;
padding: 0.75rem;
}
[data-conflict-monaco-mount] {
display: none;
width: 100%;
min-height: 18rem;
height: 32rem;
border: 1px solid rgba(127, 29, 29, 0.18);
background: #fff;
overflow: hidden;
}
[data-conflict-monaco-mount].monaco-mounted {
display: block;
}
[data-conflict-monaco-mount].monaco-mounted + textarea {
display: none;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Adaptive breakpoints */
@media (max-width: 1024px) {
.workspace-shell,
@@ -579,6 +971,10 @@ code {
}
@media (max-width: 768px) {
.site-brand img {
height: 2.25rem;
}
.site-header__inner {
flex-direction: column;
align-items: flex-start;
@@ -655,6 +1051,12 @@ code {
.meta-grid {
grid-template-columns: 1fr;
}
.document-meta-panel summary,
.document-meta__header {
align-items: flex-start;
flex-direction: column;
}
}
@media (min-width: 1600px) {
@@ -709,3 +1111,149 @@ code {
font-size: 0.85rem;
font-family: ui-monospace, SFMono-Regular, monospace;
}
@media (prefers-color-scheme: dark) {
.site-header {
border-bottom-color: oklch(0.28 0.018 55 / 0.5);
background: oklch(0.16 0.014 55 / 0.92);
}
.miller-column h2 {
background: oklch(0.20 0.015 55 / 0.72);
}
.miller-column a:hover,
.miller-column a.is-active {
background: var(--accent-soft);
}
.browser-icon--folder {
color: oklch(0.705 0.165 55);
}
.browser-icon--page {
color: oklch(0.66 0.018 55);
}
.markdown-body pre {
background: oklch(0.12 0.015 55);
color: oklch(0.93 0.006 55);
}
.markdown-body blockquote {
border-left-color: var(--accent);
background: oklch(0.705 0.165 55 / 0.07);
}
.document-meta-panel {
background: oklch(0.20 0.015 55 / 0.5);
}
.editor-form textarea {
background: oklch(0.15 0.012 55);
color: var(--text);
border-color: var(--border);
}
.editor-conflict {
border-color: oklch(0.55 0.18 25 / 0.4);
background: oklch(0.22 0.04 25);
color: oklch(0.75 0.12 25);
}
.editor-conflict__actions button {
border-color: oklch(0.55 0.18 25 / 0.4);
background: oklch(0.18 0.018 55);
color: oklch(0.75 0.12 25);
}
.editor-conflict__choices button {
border-color: oklch(0.55 0.18 25 / 0.35);
background: oklch(0.18 0.018 55);
color: oklch(0.75 0.12 25);
}
.editor-conflict__choices button[data-conflict-use="server"] {
border-color: oklch(0.65 0.18 25 / 0.45);
background: oklch(0.28 0.08 25 / 0.8);
color: oklch(0.78 0.13 25);
}
.editor-conflict__choices button[data-conflict-use="local"] {
border-color: oklch(0.65 0.15 145 / 0.45);
background: oklch(0.25 0.07 145 / 0.8);
color: oklch(0.78 0.12 145);
}
.editor-conflict__swatch--server {
background: oklch(0.35 0.12 25 / 0.65);
color: oklch(0.72 0.14 25);
}
.editor-conflict__swatch--local {
background: oklch(0.33 0.10 145 / 0.65);
color: oklch(0.72 0.13 145);
}
.editor-conflict__diff {
border-color: oklch(0.55 0.18 25 / 0.3);
background: oklch(0.14 0.012 55);
}
.editor-conflict__resolution textarea {
border-color: oklch(0.55 0.18 25 / 0.3);
background: oklch(0.14 0.012 55);
color: var(--text);
}
[data-conflict-monaco-mount] {
border-color: oklch(0.55 0.18 25 / 0.3);
background: oklch(0.14 0.012 55);
}
.offline-notice {
border-color: oklch(0.75 0.15 85 / 0.4);
background: oklch(0.22 0.03 85 / 0.95);
color: oklch(0.75 0.12 85);
}
.offline-icon {
background: oklch(0.78 0.165 85);
}
.cached-notice {
border-color: oklch(0.55 0.15 250 / 0.3);
background: oklch(0.20 0.035 250 / 0.96);
color: oklch(0.65 0.12 250);
}
.cached-icon {
background: oklch(0.60 0.15 250);
}
.version-notice button {
background: var(--accent);
}
.miller-column:not(:first-child):not(:last-child):hover,
.miller-column:not(:first-child):not(:last-child).is-preview-expanded {
box-shadow: 8px 0 24px oklch(0.08 0.02 55 / 0.3);
}
.editor-status[data-state="queued"] {
color: oklch(0.65 0.12 75);
}
.editor-status[data-state="conflict"] {
color: oklch(0.60 0.18 25);
}
.editor-status[data-state="saving"],
.editor-status[data-state="unsaved"] {
color: oklch(0.65 0.12 250);
}
.markdown-body blockquote {
background: oklch(0.705 0.165 55 / 0.07);
}
}

View File

@@ -0,0 +1,204 @@
const STATIC_CACHE = "md-hub-static-v1";
const DB_NAME = "md-hub-cache";
const DB_VERSION = 3;
const STORE_PAGES = "pages";
const STATIC_ASSETS = [
"/static/site.css",
"/static/cache.js",
"/static/sync.js",
"/static/realtime.js",
"/static/editor.js",
"/static/render.js",
"/static/favicon.png",
"/static/cairnquire%20logo%402x.webp",
"/",
"/docs",
];
self.addEventListener("install", function (event) {
event.waitUntil(
caches.open(STATIC_CACHE).then(function (cache) {
return cache.addAll(STATIC_ASSETS);
}).then(function () {
return self.skipWaiting();
})
);
});
self.addEventListener("activate", function (event) {
event.waitUntil(
caches.keys().then(function (keys) {
return Promise.all(
keys.map(function (key) {
if (key === STATIC_CACHE) {
return null;
}
return caches.delete(key);
})
);
}).then(function () {
return self.clients.claim();
})
);
});
self.addEventListener("fetch", function (event) {
const request = event.request;
if (request.method !== "GET") {
return;
}
const url = new URL(request.url);
if (url.origin !== self.location.origin) {
return;
}
if (request.mode === "navigate") {
event.respondWith(handleNavigation(request, url));
return;
}
if (url.pathname.startsWith("/static/")) {
event.respondWith(handleStaticAsset(request));
}
});
async function handleNavigation(request, url) {
try {
const response = await fetch(request);
cachePageResponse(url.pathname, response.clone());
return response;
} catch (_error) {
const cachedPage = await getCachedPage(url.pathname);
if (cachedPage && cachedPage.html) {
return new Response(markOfflineHTML(cachedPage.html), {
headers: {
"Content-Type": "text/html; charset=utf-8",
"X-MDHub-Cache": "offline",
},
status: 200,
});
}
const fallback = await caches.match("/");
if (fallback) {
return fallback;
}
return new Response("Offline", {
status: 503,
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
}
async function handleStaticAsset(request) {
const cache = await caches.open(STATIC_CACHE);
const cached = await cache.match(request);
if (cached) {
fetch(request).then(function (response) {
if (response && response.ok) {
cache.put(request, response.clone());
}
}).catch(function () {});
return cached;
}
const response = await fetch(request);
if (response && response.ok) {
cache.put(request, response.clone());
}
return response;
}
async function cachePageResponse(pathname, response) {
if (!response || !response.ok) {
return;
}
const contentType = response.headers.get("Content-Type") || "";
if (contentType.indexOf("text/html") === -1) {
return;
}
try {
const html = await response.text();
await putCachedPage(normalizePath(pathname), {
path: normalizePath(pathname),
html: html,
title: extractTitle(html),
cachedAt: Date.now(),
});
} catch (_error) {}
}
function markOfflineHTML(html) {
const marker = "<script>window.__MDHUB_OFFLINE_CACHED__=true;<\/script>";
if (html.indexOf("window.__MDHUB_OFFLINE_CACHED__") !== -1) {
return html;
}
if (html.indexOf("</head>") !== -1) {
return html.replace("</head>", marker + "</head>");
}
return marker + html;
}
function extractTitle(html) {
const match = html.match(/<title>([^<]*)<\/title>/i);
return match ? match[1] : "Cairnquire";
}
function normalizePath(path) {
if (!path || path === "/") {
return "/";
}
return path.replace(/\/$/, "") || "/";
}
function openDB() {
return new Promise(function (resolve, reject) {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = function () {
reject(request.error);
};
request.onsuccess = function () {
resolve(request.result);
};
request.onupgradeneeded = function (event) {
const db = event.target.result;
if (!db.objectStoreNames.contains(STORE_PAGES)) {
db.createObjectStore(STORE_PAGES, { keyPath: "path" });
}
};
});
}
async function putCachedPage(path, record) {
const db = await openDB();
return new Promise(function (resolve, reject) {
const tx = db.transaction(STORE_PAGES, "readwrite");
const store = tx.objectStore(STORE_PAGES);
store.put(record);
tx.oncomplete = function () {
resolve();
};
tx.onerror = function () {
reject(tx.error);
};
});
}
async function getCachedPage(path) {
const db = await openDB();
return new Promise(function (resolve, reject) {
const tx = db.transaction(STORE_PAGES, "readonly");
const store = tx.objectStore(STORE_PAGES);
const request = store.get(normalizePath(path));
request.onsuccess = function () {
resolve(request.result);
};
request.onerror = function () {
reject(request.error);
};
});
}

View File

@@ -0,0 +1,96 @@
(function () {
if (!window.MDHubCache) {
return;
}
function normalizeDocPath(path) {
if (!path) return "";
return path.replace(/^\/+/, "");
}
async function postDocument(path, content, baseHash) {
const response = await fetch("/api/documents/" + normalizeDocPath(path), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ content: content, baseHash: baseHash || "" }),
});
const payload = await response.json().catch(function () {
return {};
});
if (!response.ok) {
if (response.status === 409) {
const conflict = new Error("document conflict");
conflict.name = "DocumentConflictError";
conflict.details = payload;
throw conflict;
}
throw new Error("save failed");
}
return payload;
}
async function saveDocument(path, content, baseHash) {
const normalizedPath = window.MDHubCache.normalizePath("/docs/" + normalizeDocPath(path));
try {
const result = await postDocument(path, content, baseHash);
await window.MDHubCache.deletePendingEdit(normalizedPath);
return {
status: "saved",
result: result,
};
} catch (error) {
if (error.name === "DocumentConflictError") {
await window.MDHubCache.putPendingEdit(normalizedPath, content, baseHash);
await window.MDHubCache.markPendingEditConflict(normalizedPath, error.details);
return {
status: "conflict",
conflict: error.details,
};
}
await window.MDHubCache.putPendingEdit(normalizedPath, content, baseHash);
return {
status: "queued",
};
}
}
async function syncPending() {
if (!navigator.onLine) {
return;
}
const pending = await window.MDHubCache.getPendingEdits();
pending.sort(function (a, b) {
return (a.updatedAt || 0) - (b.updatedAt || 0);
});
for (const edit of pending) {
try {
await postDocument(edit.path.replace(/^\/docs\//, ""), edit.content, edit.baseHash);
await window.MDHubCache.deletePendingEdit(edit.path);
} catch (error) {
if (error.name === "DocumentConflictError") {
await window.MDHubCache.markPendingEditConflict(edit.path, error.details);
}
break;
}
}
}
window.addEventListener("online", function () {
syncPending().catch(function () {});
});
syncPending().catch(function () {});
window.MDHubSync = {
saveDocument: saveDocument,
syncPending: syncPending,
};
})();

View File

@@ -0,0 +1,141 @@
package httpserver
import (
"context"
"encoding/json"
"net/http"
"os"
"strings"
"github.com/go-chi/chi/v5"
"github.com/tim/cairnquire/apps/server/internal/sync"
)
func (s *Server) handleSyncInit(w http.ResponseWriter, r *http.Request) {
var req sync.InitRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if req.DeviceID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "deviceId is required"})
return
}
userID := r.Context().Value("userID").(string)
snapshot, err := s.syncService.InitSync(r.Context(), req.DeviceID, userID)
if err != nil {
s.logger.Error("sync init failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to initialize sync"})
return
}
writeJSON(w, http.StatusOK, sync.InitResponse{
SnapshotID: snapshot.ID,
ServerSnapshot: snapshot.Files,
})
}
func (s *Server) handleSyncDelta(w http.ResponseWriter, r *http.Request) {
var req sync.DeltaRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if req.SnapshotID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "snapshotId is required"})
return
}
result, err := s.syncService.ApplyDelta(r.Context(), req.SnapshotID, sync.Delta{Changes: req.ClientDelta})
if err != nil {
s.logger.Error("sync delta failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to apply delta"})
return
}
writeJSON(w, http.StatusOK, sync.DeltaResponse{
ServerDelta: result.ServerDelta,
Conflicts: result.Conflicts,
})
}
func (s *Server) handleSyncResolve(w http.ResponseWriter, r *http.Request) {
var req sync.ResolveRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if req.SnapshotID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "snapshotId is required"})
return
}
snapshot, err := s.syncService.ResolveConflicts(r.Context(), req.SnapshotID, req.Resolutions)
if err != nil {
s.logger.Error("sync resolve failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to resolve conflicts"})
return
}
writeJSON(w, http.StatusOK, sync.ResolveResponse{
NewSnapshotID: snapshot.ID,
})
}
func (s *Server) handleContentFetch(w http.ResponseWriter, r *http.Request) {
hash := chi.URLParam(r, "hash")
if hash == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hash is required"})
return
}
content, err := s.syncService.GetContent(hash)
if err != nil {
if os.IsNotExist(err) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "content not found"})
return
}
s.logger.Error("content fetch failed", "error", err, "hash", hash)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to fetch content"})
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Header().Set("ETag", "\""+hash+"\"")
w.WriteHeader(http.StatusOK)
w.Write(content)
}
func (s *Server) apiKeyMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip for non-sync routes
if !strings.HasPrefix(r.URL.Path, "/api/sync/") && !strings.HasPrefix(r.URL.Path, "/api/content/") {
next.ServeHTTP(w, r)
return
}
apiKey := r.Header.Get("X-API-Key")
if apiKey == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing API key"})
return
}
// Validate API key against database
record, err := s.syncRepo.ValidateAPIKey(r.Context(), apiKey)
if err != nil {
s.logger.Warn("invalid api key", "error", err)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid API key"})
return
}
ctx := context.WithValue(r.Context(), "userID", record.UserID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View File

@@ -1,52 +1,68 @@
{{ define "base" }}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ .Title }}</title>
<link rel="stylesheet" href="/static/site.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
<script defer src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
</head>
<body class="{{ .BodyClass }}">
<header class="site-header">
<div class="site-header__inner">
<a class="site-brand" href="/">MD Hub Secure</a>
<form class="site-search" action="/" method="get">
<input type="search" name="q" placeholder="Search..." aria-label="Search documents" />
<button type="submit" aria-label="Search">🔍</button>
</form>
<nav class="site-nav">
<a href="/">Docs</a>
{{ if .WebEnabled }}<a href="/app/">App</a>{{ end }}
<a href="/health">Health</a>
</nav>
</div>
</header>
<main class="site-main">
{{ if eq .BodyTemplate "index_content" }}
{{ template "index_content" .Data }}
{{ else if eq .BodyTemplate "document_content" }}
{{ template "document_content" .Data }}
{{ else if eq .BodyTemplate "search_content" }}
{{ template "search_content" .Data }}
{{ else if eq .BodyTemplate "error_content" }}
{{ template "error_content" .Data }}
{{ end }}
</main>
<div class="offline-notice" data-offline-notice hidden>
<span aria-hidden="true" class="offline-icon"></span>
<p>You are offline. Some content may be stale.</p>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>{{ .Title }}</title>
<link rel="icon" type="image/png" href="/static/favicon.png" />
<link rel="apple-touch-icon" href="/static/favicon.png" />
<link rel="stylesheet" href="/static/site.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
<script defer src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
</head>
<body class="{{ .BodyClass }}">
<header class="site-header">
<div class="site-header__inner">
<a class="site-brand" href="/" aria-label="Cairnquire home">
<img src="/static/cairnquire%20logo%402x.webp" alt="" width="160" height="40" decoding="async" />
<span class="sr-only">Cairnquire</span>
</a>
<form class="site-search" action="/" method="get">
<input type="search" name="q" placeholder="Search..." aria-label="Search documents" />
<button type="submit" aria-label="Search">🔍</button>
</form>
<nav class="site-nav">
<a href="/">Docs</a>
{{ if .WebEnabled }}<a href="/app/">Admin</a>{{ end }}
</nav>
</div>
<div class="version-notice" data-version-notice hidden>
<p>A newer version is available.</p>
<button type="button" data-version-reload>Reload</button>
</div>
<script src="/static/cache.js" defer></script>
<script src="/static/realtime.js" defer></script>
<script src="/static/render.js" defer></script>
</body>
</header>
<main class="site-main">
{{ if eq .BodyTemplate "index_content" }}
{{ template "index_content" .Data }}
{{ else if eq .BodyTemplate "document_content" }}
{{ template "document_content" .Data }}
{{ else if eq .BodyTemplate "document_edit_content" }}
{{ template "document_edit_content" .Data }}
{{ else if eq .BodyTemplate "search_content" }}
{{ template "search_content" .Data }}
{{ else if eq .BodyTemplate "error_content" }}
{{ template "error_content" .Data }}
{{ end }}
</main>
<div class="offline-notice" data-offline-notice hidden>
<span aria-hidden="true" class="offline-icon"></span>
<p>You are offline. Some content may be stale.</p>
</div>
<div class="cached-notice" data-cached-notice hidden>
<span aria-hidden="true" class="cached-icon"></span>
<p>Viewing cached offline content.</p>
</div>
<div class="version-notice" data-version-notice hidden>
<p>A newer version is available.</p>
<button type="button" data-version-reload>Reload</button>
</div>
<script src="/static/cache.js" defer></script>
<script src="/static/sync.js" defer></script>
<script src="/static/realtime.js" defer></script>
<script src="/static/editor.js" defer></script>
<script src="/static/render.js" defer></script>
</body>
</html>
{{ end }}

View File

@@ -19,29 +19,37 @@
</ol>
</nav>
<div class="document-meta">
<h1>{{ .Title }}</h1>
<dl class="meta-grid">
<div>
<dt>File</dt>
<dd><code>{{ .Path }}</code></dd>
</div>
<div>
<dt>Hash</dt>
<dd><code>{{ .Hash }}</code></dd>
</div>
{{ if .Tags }}
<div class="document-meta__header">
<h1>{{ .Title }}</h1>
<a class="document-edit-link" href="/docs/{{ trimMd .Path }}/edit">Edit</a>
</div>
<details class="document-meta-panel">
<summary>
<span>Document details</span>
</summary>
<dl class="meta-grid">
<div>
<dt>Tags</dt>
<dd>
<ul class="tag-list">
{{ range .Tags }}
<li><a href="/?tag={{ . }}">#{{ . }}</a></li>
{{ end }}
</ul>
</dd>
<dt>File</dt>
<dd><code>{{ .Path }}</code></dd>
</div>
{{ end }}
</dl>
<div>
<dt>Hash</dt>
<dd><code>{{ .Hash }}</code></dd>
</div>
{{ if .Tags }}
<div>
<dt>Tags</dt>
<dd>
<ul class="tag-list">
{{ range .Tags }}
<li><a href="/?tag={{ . }}">#{{ . }}</a></li>
{{ end }}
</ul>
</dd>
</div>
{{ end }}
</dl>
</details>
</div>
<div class="markdown-body">
@@ -57,7 +65,7 @@
<section class="miller-column {{ if eq .Title "Content" }}miller-column--root{{ end }}" aria-label="{{ .Title }}">
<h2 title="{{ .Title }}">
{{ if eq .Title "Content" }}
<span class="browser-icon browser-icon--root" aria-hidden="true"></span>
{{ template "icon-library" }}
{{ end }}
<span class="miller-column-title-text">{{ .Title }}</span>
</h2>
@@ -66,10 +74,10 @@
<li>
<a class="{{ if .Active }}is-active{{ end }} {{ if .IsFolder }}is-folder{{ end }}" href="{{ .URL }}" title="{{ .Name }}">
<span class="browser-item-label">
<span class="browser-icon {{ if .IsFolder }}browser-icon--folder{{ else }}browser-icon--page{{ end }}" aria-hidden="true"></span>
{{ if .IsFolder }}{{ template "icon-folder" }}{{ else }}{{ template "icon-file-text" }}{{ end }}
<span class="browser-item-name">{{ .Name }}</span>
</span>
{{ if .IsFolder }}<span class="browser-item-chevron" aria-hidden="true"></span>{{ end }}
{{ if .IsFolder }}{{ template "icon-chevron-right" }}{{ end }}
</a>
</li>
{{ end }}
@@ -78,3 +86,34 @@
{{ end }}
</nav>
{{ end }}
{{ define "icon-folder" }}
<svg class="browser-icon browser-icon--folder" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.7-.9l-.8-1.2A2 2 0 0 0 7.9 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"></path>
</svg>
{{ end }}
{{ define "icon-file-text" }}
<svg class="browser-icon browser-icon--page" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"></path>
<path d="M14 2v4a2 2 0 0 0 2 2h4"></path>
<path d="M10 9H8"></path>
<path d="M16 13H8"></path>
<path d="M16 17H8"></path>
</svg>
{{ end }}
{{ define "icon-library" }}
<svg class="browser-icon browser-icon--root" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<path d="m16 6 4 14"></path>
<path d="M12 6v14"></path>
<path d="M8 8v12"></path>
<path d="M4 4v16"></path>
</svg>
{{ end }}
{{ define "icon-chevron-right" }}
<svg class="browser-item-chevron" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
<path d="m9 18 6-6-6-6"></path>
</svg>
{{ end }}

View File

@@ -0,0 +1,86 @@
{{ define "document_edit.gohtml" }}{{ template "base" . }}{{ end }}
{{ define "document_edit_content" }}
<section class="workspace-shell workspace-shell--document-edit">
{{ template "browser" .Browser }}
<article class="document-shell document-shell--editor" data-document-path="{{ .Path }}" data-document-hash="{{ .Hash }}">
<nav class="breadcrumbs" aria-label="Breadcrumb">
<ol>
{{ $lastIdx := sub (len .Breadcrumbs) 1 }}
{{ range $i, $crumb := .Breadcrumbs }}
<li>
{{ if eq $i $lastIdx }}
<span aria-current="page">{{ $crumb.Name }}</span>
{{ else }}
<a href="{{ $crumb.URL }}">{{ $crumb.Name }}</a>
{{ end }}
</li>
{{ end }}
</ol>
</nav>
<div class="document-meta">
<div class="document-meta__header document-meta__header--editor">
<div>
<p class="eyebrow">Editing</p>
<h1>{{ .Title }}</h1>
</div>
<a class="document-edit-link" href="/docs/{{ trimMd .Path }}">Done</a>
</div>
<details class="document-meta-panel">
<summary>
<span>Editor details</span>
<span class="editor-status" data-sync-status>Saved</span>
</summary>
<dl class="meta-grid">
<div>
<dt>File</dt>
<dd><code>{{ .Path }}</code></dd>
</div>
<div>
<dt>Hash</dt>
<dd><code data-editor-hash>{{ .Hash }}</code></dd>
</div>
<div>
<dt>Status</dt>
<dd><span class="editor-status" data-sync-status-secondary>Saved</span></dd>
</div>
</dl>
</details>
<div class="editor-conflict" data-conflict-notice hidden>
<div class="editor-conflict__header">
<div>
<strong>Conflict detected.</strong>
<span>The server has a newer copy (<code data-conflict-hash></code>). Resolve it before saving more edits.</span>
</div>
<div class="editor-conflict__actions">
<button type="button" data-conflict-apply disabled>Apply Resolution</button>
<button type="button" data-conflict-dismiss>Dismiss</button>
</div>
</div>
<p data-conflict-status>Red deletions are from the server copy. Green additions are from your queued edit.</p>
<div class="editor-conflict__legend" aria-label="Diff color meaning">
<span><span class="editor-conflict__swatch editor-conflict__swatch--server"></span>Red / - Server copy</span>
<span><span class="editor-conflict__swatch editor-conflict__swatch--local"></span>Green / + Your edit</span>
</div>
<div class="editor-conflict__choices" role="group" aria-label="Resolution starting point">
<button type="button" data-conflict-use="server">Use Server</button>
<button type="button" data-conflict-use="local">Use My Edit</button>
<button type="button" data-conflict-use="both">Use Both</button>
</div>
<div class="editor-conflict__diff" data-conflict-diff></div>
<div class="editor-conflict__resolution">
<label for="conflict-resolution">Resolved document</label>
<div data-conflict-monaco-mount></div>
<textarea id="conflict-resolution" data-conflict-resolution spellcheck="false"></textarea>
</div>
</div>
</div>
<form class="editor-form" data-editor-form data-document-path="{{ .Path }}">
<label class="sr-only" for="document-editor">Markdown editor</label>
<div data-monaco-mount></div>
<textarea id="document-editor" name="content" data-document-editor spellcheck="false">{{ .Source }}</textarea>
</form>
</article>
</section>
{{ end }}

View File

@@ -5,7 +5,7 @@
{{ template "browser" .Browser }}
<section class="empty-preview">
<p class="eyebrow">Documents</p>
<h1>MD Hub Secure</h1>
<h1>Cairnquire</h1>
</section>
</section>
{{ end }}

View File

@@ -6,7 +6,7 @@ import (
"github.com/gorilla/websocket"
"github.com/tim/md-hub-secure/apps/server/internal/realtime"
"github.com/tim/cairnquire/apps/server/internal/realtime"
)
const (