Add Cloudflare email and collaboration workflows
This commit is contained in:
@@ -16,6 +16,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/tim/cairnquire/apps/server/internal/auth"
|
||||
"github.com/tim/cairnquire/apps/server/internal/collaboration"
|
||||
"github.com/tim/cairnquire/apps/server/internal/config"
|
||||
"github.com/tim/cairnquire/apps/server/internal/database"
|
||||
"github.com/tim/cairnquire/apps/server/internal/docs"
|
||||
@@ -99,6 +100,13 @@ func newAPITestServerWithSetupAndDevMode(t *testing.T, setupComplete bool, devMo
|
||||
}
|
||||
|
||||
syncRepo := sync.NewRepository(db.SQL())
|
||||
hub := realtime.NewHub(logger)
|
||||
collabService := collaboration.NewServiceWithOptions(
|
||||
collaboration.NewRepository(db.SQL()),
|
||||
hub,
|
||||
logger,
|
||||
collaboration.Options{UserLookup: auth.NewRepository(db.SQL()), PublicOrigin: "http://localhost:8080"},
|
||||
)
|
||||
handler, err := New(Dependencies{
|
||||
Config: config.Config{
|
||||
Content: config.ContentConfig{
|
||||
@@ -108,14 +116,15 @@ func newAPITestServerWithSetupAndDevMode(t *testing.T, setupComplete bool, devMo
|
||||
Auth: config.AuthConfig{PublicOrigin: "http://localhost:8080"},
|
||||
DevMode: devMode,
|
||||
},
|
||||
Logger: logger,
|
||||
Documents: docService,
|
||||
Repository: docRepo,
|
||||
ContentStore: contentStore,
|
||||
Hub: realtime.NewHub(logger),
|
||||
SyncService: sync.NewService(syncRepo, docService, contentStore, sourceDir, logger),
|
||||
SyncRepo: syncRepo,
|
||||
Auth: authService,
|
||||
Logger: logger,
|
||||
Documents: docService,
|
||||
Repository: docRepo,
|
||||
ContentStore: contentStore,
|
||||
Hub: hub,
|
||||
SyncService: sync.NewService(syncRepo, docService, contentStore, sourceDir, logger),
|
||||
SyncRepo: syncRepo,
|
||||
Auth: authService,
|
||||
Collaboration: collabService,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create http server: %v", err)
|
||||
|
||||
@@ -3,6 +3,8 @@ package httpserver
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/tim/cairnquire/apps/server/internal/collaboration"
|
||||
@@ -32,11 +34,12 @@ func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
anchorHash := r.URL.Query().Get("anchor_hash")
|
||||
includeResolved := r.URL.Query().Get("include_resolved") == "true"
|
||||
var comments []collaboration.Comment
|
||||
if anchorHash != "" {
|
||||
comments, err = s.collaboration.ListCommentsByAnchor(r.Context(), documentID, anchorHash)
|
||||
comments, err = s.collaboration.ListCommentsByAnchor(r.Context(), documentID, anchorHash, includeResolved)
|
||||
} else {
|
||||
comments, err = s.collaboration.ListComments(r.Context(), documentID)
|
||||
comments, err = s.collaboration.ListComments(r.Context(), documentID, includeResolved)
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Warn("list comments", "error", err)
|
||||
@@ -128,8 +131,13 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
unreadOnly := r.URL.Query().Get("unread") == "true"
|
||||
limit := 50
|
||||
if r.URL.Query().Get("limit") != "" {
|
||||
// Parse limit if needed, but for now just use default
|
||||
if rawLimit := r.URL.Query().Get("limit"); rawLimit != "" {
|
||||
parsed, err := strconv.Atoi(rawLimit)
|
||||
if err != nil || parsed < 1 || parsed > 100 {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "limit must be between 1 and 100"})
|
||||
return
|
||||
}
|
||||
limit = parsed
|
||||
}
|
||||
|
||||
notifications, err := s.collaboration.ListNotifications(r.Context(), principal, unreadOnly, limit)
|
||||
@@ -204,6 +212,21 @@ func (s *Server) handleWatchDocument(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
req.DocumentID = strings.TrimSpace(req.DocumentID)
|
||||
document, err := s.repository.GetDocumentByID(r.Context(), req.DocumentID)
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||
return
|
||||
}
|
||||
canRead, err := s.canReadDocumentRecord(r, *document)
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !canRead {
|
||||
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.collaboration.WatchDocument(r.Context(), principal, req.DocumentID); err != nil {
|
||||
s.logger.Warn("watch document", "error", err)
|
||||
@@ -227,6 +250,7 @@ func (s *Server) handleUnwatchDocument(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
req.DocumentID = strings.TrimSpace(req.DocumentID)
|
||||
|
||||
if err := s.collaboration.UnwatchDocument(r.Context(), principal, req.DocumentID); err != nil {
|
||||
s.logger.Warn("unwatch document", "error", err)
|
||||
@@ -250,8 +274,17 @@ func (s *Server) handleWatchFolder(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
normalized, err := collaboration.NormalizeFolderPath(req.FolderPath)
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !s.canReadFolder(r, normalized) {
|
||||
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "folder not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.collaboration.WatchFolder(r.Context(), principal, req.FolderPath); err != nil {
|
||||
if err := s.collaboration.WatchFolder(r.Context(), principal, normalized); err != nil {
|
||||
s.logger.Warn("watch folder", "error", err)
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
@@ -273,8 +306,13 @@ func (s *Server) handleUnwatchFolder(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
normalized, err := collaboration.NormalizeFolderPath(req.FolderPath)
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.collaboration.UnwatchFolder(r.Context(), principal, req.FolderPath); err != nil {
|
||||
if err := s.collaboration.UnwatchFolder(r.Context(), principal, normalized); err != nil {
|
||||
s.logger.Warn("unwatch folder", "error", err)
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
@@ -290,6 +328,20 @@ func (s *Server) handleIsWatching(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
documentID := r.URL.Query().Get("document_id")
|
||||
document, err := s.repository.GetDocumentByID(r.Context(), documentID)
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||
return
|
||||
}
|
||||
canRead, err := s.canReadDocumentRecord(r, *document)
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !canRead {
|
||||
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||
return
|
||||
}
|
||||
watching, err := s.collaboration.IsWatching(r.Context(), principal, documentID)
|
||||
if err != nil {
|
||||
s.logger.Warn("is watching", "error", err)
|
||||
|
||||
141
apps/server/internal/httpserver/collab_handlers_test.go
Normal file
141
apps/server/internal/httpserver/collab_handlers_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/tim/cairnquire/apps/server/internal/auth"
|
||||
"github.com/tim/cairnquire/apps/server/internal/collaboration"
|
||||
)
|
||||
|
||||
func loginTestUser(t *testing.T, server *apiTestServer) *http.Cookie {
|
||||
t.Helper()
|
||||
login := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(login, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
|
||||
"email": "editor@example.com", "password": "very secure password",
|
||||
}))
|
||||
if login.Code != http.StatusOK {
|
||||
t.Fatalf("login response = %d %q", login.Code, login.Body.String())
|
||||
}
|
||||
return cookieByName(t, login.Result().Cookies(), auth.SessionCookieName)
|
||||
}
|
||||
|
||||
func authenticatedJSONRequest(t *testing.T, method, target string, body any, session *http.Cookie) *http.Request {
|
||||
t.Helper()
|
||||
request := jsonRequest(method, target, body)
|
||||
request.AddCookie(session)
|
||||
return request
|
||||
}
|
||||
|
||||
func TestDocumentLoadsCollaborationControlsAndWatchFlow(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
session := loginTestUser(t, server)
|
||||
|
||||
pageRequest := httptest.NewRequest(http.MethodGet, "/docs/hello", nil)
|
||||
pageRequest.AddCookie(session)
|
||||
page := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(page, pageRequest)
|
||||
if page.Code != http.StatusOK {
|
||||
t.Fatalf("document response = %d %q", page.Code, page.Body.String())
|
||||
}
|
||||
for _, expected := range [][]byte{
|
||||
[]byte(`/static/notification-bell.js`),
|
||||
[]byte(`data-document-id="doc:hello"`),
|
||||
[]byte(`data-document-watch`),
|
||||
[]byte(`data-comments-history`),
|
||||
} {
|
||||
if !bytes.Contains(page.Body.Bytes(), expected) {
|
||||
t.Errorf("document page missing %q", expected)
|
||||
}
|
||||
}
|
||||
|
||||
statusRequest := httptest.NewRequest(http.MethodGet, "/api/watch/status?document_id=doc%3Ahello", nil)
|
||||
statusRequest.AddCookie(session)
|
||||
status := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(status, statusRequest)
|
||||
if status.Code != http.StatusOK || !bytes.Contains(status.Body.Bytes(), []byte(`"watching":false`)) {
|
||||
t.Fatalf("initial watch status = %d %q", status.Code, status.Body.String())
|
||||
}
|
||||
|
||||
watch := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(watch, authenticatedJSONRequest(t, http.MethodPost, "/api/watch/document", map[string]string{"documentId": "doc:hello"}, session))
|
||||
if watch.Code != http.StatusOK {
|
||||
t.Fatalf("watch response = %d %q", watch.Code, watch.Body.String())
|
||||
}
|
||||
|
||||
status = httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(status, statusRequest.Clone(statusRequest.Context()))
|
||||
if status.Code != http.StatusOK || !bytes.Contains(status.Body.Bytes(), []byte(`"watching":true`)) {
|
||||
t.Fatalf("watched status = %d %q", status.Code, status.Body.String())
|
||||
}
|
||||
|
||||
unwatch := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(unwatch, authenticatedJSONRequest(t, http.MethodDelete, "/api/watch/document", map[string]string{"documentId": "doc:hello"}, session))
|
||||
if unwatch.Code != http.StatusOK {
|
||||
t.Fatalf("unwatch response = %d %q", unwatch.Code, unwatch.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentReplyResolveAndHistoryHTTPFlow(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
session := loginTestUser(t, server)
|
||||
anchor := "section-anchor"
|
||||
|
||||
create := func(content string, parentID *string) collaboration.Comment {
|
||||
t.Helper()
|
||||
response := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(response, authenticatedJSONRequest(t, http.MethodPost, "/api/comments", collaboration.CreateCommentInput{
|
||||
DocumentID: "doc:hello", VersionHash: "hash", ParentID: parentID, Content: content, AnchorHash: &anchor,
|
||||
}, session))
|
||||
if response.Code != http.StatusCreated {
|
||||
t.Fatalf("create comment response = %d %q", response.Code, response.Body.String())
|
||||
}
|
||||
var comment collaboration.Comment
|
||||
if err := json.NewDecoder(response.Body).Decode(&comment); err != nil {
|
||||
t.Fatalf("decode comment: %v", err)
|
||||
}
|
||||
return comment
|
||||
}
|
||||
|
||||
root := create("Root", nil)
|
||||
reply := create("Reply", &root.ID)
|
||||
_ = create("Grandchild", &reply.ID)
|
||||
|
||||
resolve := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(resolve, authenticatedJSONRequest(t, http.MethodPost, "/api/comments/"+root.ID+"/resolve", map[string]any{}, session))
|
||||
if resolve.Code != http.StatusOK {
|
||||
t.Fatalf("resolve response = %d %q", resolve.Code, resolve.Body.String())
|
||||
}
|
||||
|
||||
list := func(includeResolved bool) []collaboration.Comment {
|
||||
t.Helper()
|
||||
target := "/api/comments?document_id=doc%3Ahello&anchor_hash=" + anchor
|
||||
if includeResolved {
|
||||
target += "&include_resolved=true"
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, target, nil)
|
||||
request.AddCookie(session)
|
||||
response := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("list comments response = %d %q", response.Code, response.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Comments []collaboration.Comment `json:"comments"`
|
||||
}
|
||||
if err := json.NewDecoder(response.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode comments: %v", err)
|
||||
}
|
||||
return body.Comments
|
||||
}
|
||||
|
||||
if active := list(false); len(active) != 0 {
|
||||
t.Fatalf("active comments = %d, want 0", len(active))
|
||||
}
|
||||
if history := list(true); len(history) != 3 {
|
||||
t.Fatalf("history comments = %d, want 3", len(history))
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ type indexData struct {
|
||||
|
||||
type documentData struct {
|
||||
Browser browserData
|
||||
ID string
|
||||
Title string
|
||||
Path string
|
||||
Tags []string
|
||||
@@ -375,6 +376,7 @@ func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, page
|
||||
BodyTemplate: "document_content",
|
||||
Data: documentData{
|
||||
Browser: browser,
|
||||
ID: record.ID,
|
||||
Title: page.Title,
|
||||
Path: page.Path,
|
||||
Tags: page.Tags,
|
||||
|
||||
@@ -207,7 +207,10 @@ func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
|
||||
case strings.HasPrefix(path, "/api/content/"):
|
||||
return auth.ScopeSyncRead, true
|
||||
case strings.HasPrefix(path, "/api/comments"):
|
||||
return auth.ScopeDocsRead, true
|
||||
if method == http.MethodGet {
|
||||
return auth.ScopeDocsRead, true
|
||||
}
|
||||
return auth.ScopeDocsWrite, true
|
||||
case strings.HasPrefix(path, "/api/notifications"):
|
||||
return auth.ScopeDocsRead, true
|
||||
case strings.HasPrefix(path, "/api/watch"):
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const documentPath = document.querySelector('[data-document-path]')?.dataset.documentPath;
|
||||
const documentHash = document.querySelector('[data-document-hash]')?.dataset.documentHash;
|
||||
const canComment = document.querySelector('[data-document-path]')?.dataset.canComment === 'true';
|
||||
const article = document.querySelector('[data-document-path]');
|
||||
const workspaceShell = article?.closest('.workspace-shell--document');
|
||||
const article = document.querySelector('[data-document-id]');
|
||||
if (!article) return;
|
||||
|
||||
const documentId = article.dataset.documentId;
|
||||
const documentHash = article.dataset.documentHash;
|
||||
const canComment = article.dataset.canComment === 'true';
|
||||
const workspaceShell = article.closest('.workspace-shell--document');
|
||||
const commentsAsideList = document.querySelector('[data-comments-aside-list]');
|
||||
const commentsAsideListMobile = document.querySelector('[data-comments-aside-list-mobile]');
|
||||
const stripTrack = document.querySelector('[data-comments-strip-track]');
|
||||
const commentsAside = document.querySelector('[data-comments-aside]');
|
||||
const commentsSheetToggle = document.querySelector('[data-toggle-comments]');
|
||||
const commentsSheetClose = document.querySelector('[data-close-comments]');
|
||||
const documentId = documentPath ? 'doc:' + documentPath.replace(/\.md$/, '') : null;
|
||||
const commentToggle = document.querySelector('[data-comment-toggle]');
|
||||
const commentVisibilityKey = documentId ? `cairnquire:document-comments:${documentId}` : null;
|
||||
const watchButton = document.querySelector('[data-document-watch]');
|
||||
const historyButtons = document.querySelectorAll('[data-comments-history]');
|
||||
const commentVisibilityKey = `cairnquire:document-comments:${documentId}`;
|
||||
const commentsByAnchor = new Map();
|
||||
const elementsByAnchor = new Map();
|
||||
let includeResolved = false;
|
||||
let watching = false;
|
||||
|
||||
function readCommentVisibility() {
|
||||
if (!commentVisibilityKey) return false;
|
||||
try {
|
||||
return window.localStorage.getItem(commentVisibilityKey) === 'hidden';
|
||||
} catch (err) {
|
||||
@@ -25,29 +31,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
if (!documentId) return;
|
||||
|
||||
function syncVisibility() {
|
||||
const hidden = readCommentVisibility();
|
||||
if (article) article.classList.toggle('comments-hidden', hidden);
|
||||
if (workspaceShell) workspaceShell.classList.toggle('comments-visible', !hidden);
|
||||
}
|
||||
|
||||
syncVisibility();
|
||||
|
||||
function commentsHidden() {
|
||||
return Boolean(workspaceShell?.classList.contains('comments-visible')) === false;
|
||||
return !workspaceShell?.classList.contains('comments-visible');
|
||||
}
|
||||
|
||||
function setCommentsHidden(hidden) {
|
||||
if (article) article.classList.toggle('comments-hidden', hidden);
|
||||
if (workspaceShell) workspaceShell.classList.toggle('comments-visible', !hidden);
|
||||
if (commentVisibilityKey) {
|
||||
try {
|
||||
window.localStorage.setItem(commentVisibilityKey, hidden ? 'hidden' : 'visible');
|
||||
} catch (err) {
|
||||
// Ignore persistence failures
|
||||
}
|
||||
article.classList.toggle('comments-hidden', hidden);
|
||||
workspaceShell?.classList.toggle('comments-visible', !hidden);
|
||||
try {
|
||||
window.localStorage.setItem(commentVisibilityKey, hidden ? 'hidden' : 'visible');
|
||||
} catch (err) {
|
||||
// Storage can be unavailable in private browsing contexts.
|
||||
}
|
||||
if (commentToggle) {
|
||||
commentToggle.setAttribute('aria-pressed', hidden ? 'false' : 'true');
|
||||
@@ -60,173 +54,218 @@
|
||||
}
|
||||
}
|
||||
|
||||
if (commentToggle) {
|
||||
commentToggle.setAttribute('aria-pressed', commentsHidden() ? 'false' : 'true');
|
||||
commentToggle.addEventListener('click', () => {
|
||||
setCommentsHidden(!commentsHidden());
|
||||
});
|
||||
}
|
||||
setCommentsHidden(readCommentVisibility());
|
||||
commentToggle?.addEventListener('click', () => setCommentsHidden(!commentsHidden()));
|
||||
|
||||
function hashParagraph(el) {
|
||||
const text = (el.textContent || '').trim();
|
||||
if (!text) return null;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash;
|
||||
hash = ((hash << 5) - hash) + text.charCodeAt(i);
|
||||
hash &= hash;
|
||||
}
|
||||
return 'h' + Math.abs(hash).toString(36);
|
||||
}
|
||||
|
||||
function addCommentButton(el, hash) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'comment-anchor-btn';
|
||||
btn.type = 'button';
|
||||
btn.title = 'Add comment';
|
||||
btn.setAttribute('aria-label', 'Add comment');
|
||||
btn.innerHTML = `
|
||||
function addCommentButton(el, anchorHash) {
|
||||
const button = document.createElement('button');
|
||||
button.className = 'comment-anchor-btn';
|
||||
button.type = 'button';
|
||||
button.title = 'Add comment';
|
||||
button.setAttribute('aria-label', 'Add comment');
|
||||
button.innerHTML = `
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H8l-5 4V7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"></path>
|
||||
<path d="M12 8v4"></path>
|
||||
<path d="M10 10h4"></path>
|
||||
<path d="M12 8v4"></path><path d="M10 10h4"></path>
|
||||
</svg>
|
||||
<span class="comment-anchor-count" aria-hidden="true" hidden></span>
|
||||
<span class="sr-only">Add comment</span>
|
||||
`;
|
||||
btn.addEventListener('click', () => promptComment(el, hash));
|
||||
el.appendChild(btn);
|
||||
<span class="sr-only">Add comment</span>`;
|
||||
button.addEventListener('click', () => createComment(anchorHash));
|
||||
el.appendChild(button);
|
||||
}
|
||||
|
||||
function updateCommentButtonState(el, count) {
|
||||
const btn = el.querySelector('.comment-anchor-btn');
|
||||
if (!btn) return;
|
||||
const hasComments = count > 0;
|
||||
btn.classList.toggle('comment-anchor-btn--has-comments', hasComments);
|
||||
btn.setAttribute('aria-label', hasComments ? `${count} comment${count === 1 ? '' : 's'}` : 'Add comment');
|
||||
btn.title = hasComments ? `${count} comment${count === 1 ? '' : 's'}` : 'Add comment';
|
||||
const countNode = btn.querySelector('.comment-anchor-count');
|
||||
if (countNode) {
|
||||
countNode.hidden = !hasComments;
|
||||
countNode.textContent = count > 99 ? '99+' : String(count);
|
||||
function updateCommentButtonState(el, comments) {
|
||||
const button = el.querySelector('.comment-anchor-btn');
|
||||
const count = comments.filter(comment => !comment.resolvedAt).length;
|
||||
if (button) {
|
||||
const hasComments = count > 0;
|
||||
button.classList.toggle('comment-anchor-btn--has-comments', hasComments);
|
||||
const label = hasComments ? `${count} comment${count === 1 ? '' : 's'}` : 'Add comment';
|
||||
button.setAttribute('aria-label', label);
|
||||
button.title = label;
|
||||
const countNode = button.querySelector('.comment-anchor-count');
|
||||
if (countNode) {
|
||||
countNode.hidden = !hasComments;
|
||||
countNode.textContent = count > 99 ? '99+' : String(count);
|
||||
}
|
||||
}
|
||||
el.dataset.hasComments = comments.length > 0 ? 'true' : 'false';
|
||||
}
|
||||
|
||||
async function promptComment(el, anchorHash) {
|
||||
const text = window.prompt('Comment on this paragraph:');
|
||||
if (!text || !text.trim()) return;
|
||||
async function createComment(anchorHash, parentId) {
|
||||
const promptText = parentId ? 'Reply to this comment:' : 'Comment on this section:';
|
||||
const content = window.prompt(promptText);
|
||||
if (!content || !content.trim()) return;
|
||||
try {
|
||||
const res = await fetch('/api/comments', {
|
||||
const response = await fetch('/api/comments', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ documentId, versionHash: documentHash, content: text.trim(), anchorHash }),
|
||||
body: JSON.stringify({
|
||||
documentId,
|
||||
versionHash: documentHash,
|
||||
content: content.trim(),
|
||||
anchorHash,
|
||||
parentId: parentId || undefined,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await loadCommentsForAnchor(el, anchorHash);
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
await loadCommentsForAnchor(anchorHash);
|
||||
} catch (err) {
|
||||
window.alert('Failed to post comment: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCommentsForAnchor(el, anchorHash) {
|
||||
async function resolveThread(comment, anchorHash) {
|
||||
if (!window.confirm('Resolve this comment thread?')) return;
|
||||
try {
|
||||
const res = await fetch(`/api/comments?document_id=${encodeURIComponent(documentId)}&anchor_hash=${encodeURIComponent(anchorHash)}`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
renderComments(el, data.comments || []);
|
||||
const response = await fetch(`/api/comments/${encodeURIComponent(comment.id)}/resolve`, { method: 'POST' });
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
await loadCommentsForAnchor(anchorHash);
|
||||
} catch (err) {
|
||||
window.alert('Failed to resolve comment: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCommentsForAnchor(anchorHash) {
|
||||
const element = elementsByAnchor.get(anchorHash);
|
||||
if (!element) return;
|
||||
const params = new URLSearchParams({ document_id: documentId, anchor_hash: anchorHash });
|
||||
if (includeResolved) params.set('include_resolved', 'true');
|
||||
try {
|
||||
const response = await fetch(`/api/comments?${params}`);
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
const comments = data.comments || [];
|
||||
commentsByAnchor.set(anchorHash, comments);
|
||||
renderInlineComments(element, anchorHash, comments);
|
||||
renderCommentsAside();
|
||||
updateStripMarkers();
|
||||
} catch (err) {
|
||||
console.error('load comments', err);
|
||||
}
|
||||
}
|
||||
|
||||
function renderComments(el, comments) {
|
||||
const existing = el.querySelector('.comment-list');
|
||||
if (existing) existing.remove();
|
||||
|
||||
updateCommentButtonState(el, comments.length);
|
||||
el.dataset.hasComments = comments.length > 0 ? 'true' : 'false';
|
||||
|
||||
if (!comments.length) {
|
||||
renderCommentsAside();
|
||||
updateStripMarkers();
|
||||
return;
|
||||
}
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'comment-list';
|
||||
comments.forEach(c => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'comment-item';
|
||||
item.innerHTML = `
|
||||
<div class="comment-meta"><strong>${escapeHtml(c.authorName || 'Anonymous')}</strong><time>${formatDate(c.createdAt)}</time></div>
|
||||
<div class="comment-body">${escapeHtml(c.content)}</div>
|
||||
`;
|
||||
list.appendChild(item);
|
||||
function buildCommentForest(comments) {
|
||||
const nodes = new Map(comments.map(comment => [comment.id, { comment, children: [] }]));
|
||||
const roots = [];
|
||||
comments.forEach(comment => {
|
||||
const node = nodes.get(comment.id);
|
||||
const parent = comment.parentId ? nodes.get(comment.parentId) : null;
|
||||
if (parent) parent.children.push(node);
|
||||
else roots.push(node);
|
||||
});
|
||||
el.appendChild(list);
|
||||
|
||||
renderCommentsAside();
|
||||
updateStripMarkers();
|
||||
return roots;
|
||||
}
|
||||
|
||||
function buildAsideItemFor(el) {
|
||||
const comments = [];
|
||||
el.querySelectorAll('.comment-item').forEach(item => {
|
||||
const meta = item.querySelector('.comment-meta');
|
||||
const bodyEl = item.querySelector('.comment-body');
|
||||
comments.push({
|
||||
authorName: meta?.querySelector('strong')?.textContent || 'Anonymous',
|
||||
createdAt: meta?.querySelector('time')?.textContent || '',
|
||||
content: bodyEl?.textContent || '',
|
||||
});
|
||||
});
|
||||
function renderThreadNode(node, anchorHash, variant) {
|
||||
const comment = node.comment;
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = `comment-thread${comment.resolvedAt ? ' is-resolved' : ''}`;
|
||||
wrapper.dataset.commentId = comment.id;
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = variant === 'aside' ? 'comments-aside-item__comment' : 'comment-item';
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = variant === 'aside' ? 'comments-aside-item__meta' : 'comment-meta';
|
||||
const author = document.createElement('strong');
|
||||
author.textContent = comment.authorName || 'Anonymous';
|
||||
const time = document.createElement('time');
|
||||
time.textContent = formatDate(comment.createdAt);
|
||||
meta.append(author, time);
|
||||
if (comment.resolvedAt) {
|
||||
const status = document.createElement('span');
|
||||
status.className = 'comment-status';
|
||||
status.textContent = 'Resolved';
|
||||
meta.appendChild(status);
|
||||
}
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = variant === 'aside' ? 'comments-aside-item__body' : 'comment-body';
|
||||
body.textContent = comment.content;
|
||||
item.append(meta, body);
|
||||
|
||||
if (canComment && !comment.resolvedAt) {
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'comment-actions';
|
||||
const reply = document.createElement('button');
|
||||
reply.type = 'button';
|
||||
reply.className = 'comment-action';
|
||||
reply.textContent = 'Reply';
|
||||
reply.addEventListener('click', () => createComment(anchorHash, comment.id));
|
||||
actions.appendChild(reply);
|
||||
if (!comment.parentId) {
|
||||
const resolve = document.createElement('button');
|
||||
resolve.type = 'button';
|
||||
resolve.className = 'comment-action';
|
||||
resolve.textContent = 'Resolve thread';
|
||||
resolve.addEventListener('click', () => resolveThread(comment, anchorHash));
|
||||
actions.appendChild(resolve);
|
||||
}
|
||||
item.appendChild(actions);
|
||||
}
|
||||
wrapper.appendChild(item);
|
||||
|
||||
if (node.children.length) {
|
||||
const replies = document.createElement('div');
|
||||
replies.className = 'comment-thread__replies';
|
||||
node.children.forEach(child => replies.appendChild(renderThreadNode(child, anchorHash, variant)));
|
||||
wrapper.appendChild(replies);
|
||||
}
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function renderInlineComments(el, anchorHash, comments) {
|
||||
el.querySelector('.comment-list')?.remove();
|
||||
updateCommentButtonState(el, comments);
|
||||
if (!comments.length) return;
|
||||
const list = document.createElement('div');
|
||||
list.className = 'comment-list';
|
||||
buildCommentForest(comments).forEach(root => list.appendChild(renderThreadNode(root, anchorHash, 'inline')));
|
||||
el.appendChild(list);
|
||||
}
|
||||
|
||||
function buildAsideItem(anchorHash, el, comments) {
|
||||
if (!comments.length) return null;
|
||||
|
||||
const targetText = (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 120);
|
||||
const truncated = targetText.length > 120 ? targetText + '…' : targetText;
|
||||
|
||||
const asideItem = document.createElement('div');
|
||||
asideItem.className = 'comments-aside-item';
|
||||
asideItem.dataset.anchorHash = el.dataset.anchorHash;
|
||||
|
||||
const targetEl = document.createElement('div');
|
||||
targetEl.className = 'comments-aside-item__target';
|
||||
targetEl.textContent = truncated || 'Commented section';
|
||||
targetEl.addEventListener('click', () => {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
asideItem.appendChild(targetEl);
|
||||
|
||||
comments.forEach(c => {
|
||||
const commentEl = document.createElement('div');
|
||||
commentEl.className = 'comments-aside-item__comment';
|
||||
commentEl.innerHTML = `
|
||||
<div class="comments-aside-item__meta"><strong>${escapeHtml(c.authorName)}</strong><time>${escapeHtml(c.createdAt)}</time></div>
|
||||
<div class="comments-aside-item__body">${escapeHtml(c.content)}</div>
|
||||
`;
|
||||
asideItem.appendChild(commentEl);
|
||||
});
|
||||
asideItem.dataset.anchorHash = anchorHash;
|
||||
|
||||
const target = document.createElement('button');
|
||||
target.className = 'comments-aside-item__target';
|
||||
target.type = 'button';
|
||||
target.textContent = el.dataset.commentTarget || 'Commented section';
|
||||
target.addEventListener('click', () => el.scrollIntoView({ behavior: 'smooth', block: 'center' }));
|
||||
asideItem.appendChild(target);
|
||||
buildCommentForest(comments).forEach(root => asideItem.appendChild(renderThreadNode(root, anchorHash, 'aside')));
|
||||
return asideItem;
|
||||
}
|
||||
|
||||
function renderCommentsInto(container) {
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
|
||||
const body = document.querySelector('.markdown-body');
|
||||
if (!body) return;
|
||||
|
||||
const commentables = body.querySelectorAll('.commentable[data-has-comments="true"]');
|
||||
if (!commentables.length) {
|
||||
container.innerHTML = '<p class="document-comments-aside__empty">No comments yet.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
commentables.forEach(el => {
|
||||
const item = buildAsideItemFor(el);
|
||||
if (item) container.appendChild(item);
|
||||
let rendered = 0;
|
||||
elementsByAnchor.forEach((el, anchorHash) => {
|
||||
const item = buildAsideItem(anchorHash, el, commentsByAnchor.get(anchorHash) || []);
|
||||
if (item) {
|
||||
container.appendChild(item);
|
||||
rendered++;
|
||||
}
|
||||
});
|
||||
if (!rendered) {
|
||||
container.innerHTML = `<p class="document-comments-aside__empty">${includeResolved ? 'No comments found.' : 'No active comments yet.'}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderCommentsAside() {
|
||||
@@ -237,177 +276,148 @@
|
||||
function updateStripMarkers() {
|
||||
if (!stripTrack) return;
|
||||
stripTrack.innerHTML = '';
|
||||
|
||||
const body = document.querySelector('.markdown-body');
|
||||
const shell = document.querySelector('.document-shell');
|
||||
if (!body || !shell) return;
|
||||
|
||||
const commentables = body.querySelectorAll('.commentable[data-has-comments="true"]');
|
||||
const shellRect = shell.getBoundingClientRect();
|
||||
const trackHeight = stripTrack.clientHeight;
|
||||
|
||||
commentables.forEach(el => {
|
||||
if (!shell) return;
|
||||
elementsByAnchor.forEach((el, anchorHash) => {
|
||||
if (!(commentsByAnchor.get(anchorHash) || []).length) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const shellRect = shell.getBoundingClientRect();
|
||||
const relativeTop = rect.top - shellRect.top + shell.scrollTop;
|
||||
const percent = (relativeTop / shell.scrollHeight) * 100;
|
||||
const clamped = Math.max(0, Math.min(95, percent));
|
||||
|
||||
const marker = document.createElement('button');
|
||||
marker.className = 'document-comments-strip__marker';
|
||||
marker.type = 'button';
|
||||
marker.style.top = clamped + '%';
|
||||
marker.dataset.anchorHash = el.dataset.anchorHash;
|
||||
marker.style.top = Math.max(0, Math.min(95, percent)) + '%';
|
||||
marker.dataset.anchorHash = anchorHash;
|
||||
marker.title = 'Jump to comments';
|
||||
marker.addEventListener('click', () => {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
if (commentsHidden()) setCommentsHidden(false);
|
||||
// Highlight the corresponding sidebar item
|
||||
const asideItem = commentsAsideList?.querySelector(`[data-anchor-hash="${el.dataset.anchorHash}"]`);
|
||||
if (asideItem) {
|
||||
asideItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
asideItem.classList.add('is-highlighted');
|
||||
setTimeout(() => asideItem.classList.remove('is-highlighted'), 1500);
|
||||
}
|
||||
commentsAsideList?.querySelector(`[data-anchor-hash="${anchorHash}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
stripTrack.appendChild(marker);
|
||||
});
|
||||
}
|
||||
|
||||
let bidirectionalHoverInitialized = false;
|
||||
function initBidirectionalHover() {
|
||||
if (bidirectionalHoverInitialized) return;
|
||||
bidirectionalHoverInitialized = true;
|
||||
|
||||
const body = document.querySelector('.markdown-body');
|
||||
if (!body || !commentsAsideList) return;
|
||||
|
||||
// Body → sidebar + marker
|
||||
body.addEventListener('mouseenter', (e) => {
|
||||
const el = e.target.closest('.commentable[data-has-comments="true"]');
|
||||
if (!el) return;
|
||||
const hash = el.dataset.anchorHash;
|
||||
const asideItem = commentsAsideList.querySelector(`[data-anchor-hash="${hash}"]`);
|
||||
const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`);
|
||||
if (asideItem) asideItem.classList.add('is-highlighted');
|
||||
if (marker) marker.classList.add('is-active');
|
||||
el.classList.add('is-highlighted');
|
||||
}, true);
|
||||
|
||||
body.addEventListener('mouseleave', (e) => {
|
||||
const el = e.target.closest('.commentable[data-has-comments="true"]');
|
||||
if (!el) return;
|
||||
const hash = el.dataset.anchorHash;
|
||||
const asideItem = commentsAsideList.querySelector(`[data-anchor-hash="${hash}"]`);
|
||||
const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`);
|
||||
if (asideItem) asideItem.classList.remove('is-highlighted');
|
||||
if (marker) marker.classList.remove('is-active');
|
||||
el.classList.remove('is-highlighted');
|
||||
}, true);
|
||||
|
||||
// Sidebar → paragraph + marker (event delegation so it survives re-renders)
|
||||
commentsAsideList.addEventListener('mouseenter', (e) => {
|
||||
const item = e.target.closest('.comments-aside-item');
|
||||
if (!item) return;
|
||||
const hash = item.dataset.anchorHash;
|
||||
const para = body.querySelector(`[data-anchor-hash="${hash}"]`);
|
||||
const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`);
|
||||
if (para) para.classList.add('is-highlighted');
|
||||
if (marker) marker.classList.add('is-active');
|
||||
item.classList.add('is-highlighted');
|
||||
}, true);
|
||||
|
||||
commentsAsideList.addEventListener('mouseleave', (e) => {
|
||||
const item = e.target.closest('.comments-aside-item');
|
||||
if (!item) return;
|
||||
const hash = item.dataset.anchorHash;
|
||||
const para = body.querySelector(`[data-anchor-hash="${hash}"]`);
|
||||
const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`);
|
||||
if (para) para.classList.remove('is-highlighted');
|
||||
if (marker) marker.classList.remove('is-active');
|
||||
item.classList.remove('is-highlighted');
|
||||
}, true);
|
||||
body.addEventListener('mouseover', event => toggleHighlight(event.target.closest('.commentable'), true));
|
||||
body.addEventListener('mouseout', event => toggleHighlight(event.target.closest('.commentable'), false));
|
||||
commentsAsideList.addEventListener('mouseover', event => toggleAsideHighlight(event.target.closest('.comments-aside-item'), true));
|
||||
commentsAsideList.addEventListener('mouseout', event => toggleAsideHighlight(event.target.closest('.comments-aside-item'), false));
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
function toggleHighlight(el, active) {
|
||||
if (!el) return;
|
||||
const hash = el.dataset.anchorHash;
|
||||
el.classList.toggle('is-highlighted', active);
|
||||
commentsAsideList?.querySelector(`[data-anchor-hash="${hash}"]`)?.classList.toggle('is-highlighted', active);
|
||||
stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`)?.classList.toggle('is-active', active);
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
function commentsSheetOpen() {
|
||||
return commentsAside?.classList.contains('is-open');
|
||||
function toggleAsideHighlight(item, active) {
|
||||
if (!item) return;
|
||||
toggleHighlight(elementsByAnchor.get(item.dataset.anchorHash), active);
|
||||
}
|
||||
|
||||
function setCommentsSheetOpen(open) {
|
||||
if (!commentsAside) return;
|
||||
commentsAside.classList.toggle('is-open', open);
|
||||
if (commentsSheetToggle) {
|
||||
commentsSheetToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
commentsSheetToggle.setAttribute('aria-label', open ? 'Hide comments' : 'Show comments');
|
||||
}
|
||||
commentsSheetToggle?.setAttribute('aria-expanded', String(open));
|
||||
if (open) {
|
||||
document.body.classList.add('drawer-open');
|
||||
renderCommentsAside();
|
||||
} else {
|
||||
if (!pickerOpen() && !metaOpen()) {
|
||||
document.body.classList.remove('drawer-open');
|
||||
}
|
||||
} else if (!document.querySelector('[data-picker-drawer].is-open, [data-meta-drawer].is-open')) {
|
||||
document.body.classList.remove('drawer-open');
|
||||
}
|
||||
}
|
||||
|
||||
function pickerOpen() {
|
||||
return document.querySelector('[data-picker-drawer]')?.classList.contains('is-open');
|
||||
commentsSheetToggle?.addEventListener('click', () => setCommentsSheetOpen(!commentsAside?.classList.contains('is-open')));
|
||||
commentsSheetClose?.addEventListener('click', () => setCommentsSheetOpen(false));
|
||||
|
||||
function setWatchState(nextWatching) {
|
||||
watching = nextWatching;
|
||||
if (!watchButton) return;
|
||||
watchButton.setAttribute('aria-pressed', String(watching));
|
||||
const label = watchButton.querySelector('[data-document-watch-label]');
|
||||
if (label) label.textContent = watching ? 'Unwatch document' : 'Watch document';
|
||||
}
|
||||
|
||||
function metaOpen() {
|
||||
return document.querySelector('[data-meta-drawer]')?.classList.contains('is-open');
|
||||
async function loadWatchStatus() {
|
||||
if (!watchButton) return;
|
||||
try {
|
||||
const response = await fetch(`/api/watch/status?document_id=${encodeURIComponent(documentId)}`);
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
setWatchState(Boolean(data.watching));
|
||||
} catch (err) {
|
||||
console.error('load watch status', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (commentsSheetToggle) {
|
||||
commentsSheetToggle.addEventListener('click', () => {
|
||||
setCommentsSheetOpen(!commentsSheetOpen());
|
||||
async function toggleWatch() {
|
||||
if (!watchButton) return;
|
||||
watchButton.disabled = true;
|
||||
try {
|
||||
const response = await fetch('/api/watch/document', {
|
||||
method: watching ? 'DELETE' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ documentId }),
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
setWatchState(!watching);
|
||||
} catch (err) {
|
||||
window.alert('Failed to update watch: ' + err.message);
|
||||
} finally {
|
||||
watchButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
watchButton?.addEventListener('click', toggleWatch);
|
||||
|
||||
function updateHistoryButtons() {
|
||||
historyButtons.forEach(button => {
|
||||
button.setAttribute('aria-pressed', String(includeResolved));
|
||||
button.textContent = includeResolved ? 'Hide resolved' : 'Show resolved';
|
||||
});
|
||||
}
|
||||
|
||||
if (commentsSheetClose) {
|
||||
commentsSheetClose.addEventListener('click', () => setCommentsSheetOpen(false));
|
||||
historyButtons.forEach(button => button.addEventListener('click', async () => {
|
||||
includeResolved = !includeResolved;
|
||||
updateHistoryButtons();
|
||||
await Promise.all(Array.from(elementsByAnchor.keys(), loadCommentsForAnchor));
|
||||
}));
|
||||
|
||||
function formatDate(iso) {
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
|
||||
function init() {
|
||||
const body = document.querySelector('.markdown-body');
|
||||
if (!body) return;
|
||||
|
||||
const paragraphs = body.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, blockquote, pre');
|
||||
paragraphs.forEach(el => {
|
||||
const hash = hashParagraph(el);
|
||||
if (!hash) return;
|
||||
el.dataset.anchorHash = hash;
|
||||
const commentables = body.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, blockquote, pre');
|
||||
commentables.forEach(el => {
|
||||
const targetText = (el.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
const anchorHash = hashParagraph(el);
|
||||
if (!anchorHash) return;
|
||||
el.dataset.anchorHash = anchorHash;
|
||||
el.dataset.commentTarget = targetText.length > 120 ? targetText.slice(0, 120) + '…' : targetText;
|
||||
el.classList.add('commentable');
|
||||
if (canComment) addCommentButton(el, hash);
|
||||
loadCommentsForAnchor(el, hash);
|
||||
elementsByAnchor.set(anchorHash, el);
|
||||
if (canComment) addCommentButton(el, anchorHash);
|
||||
loadCommentsForAnchor(anchorHash);
|
||||
});
|
||||
|
||||
renderCommentsAside();
|
||||
initBidirectionalHover();
|
||||
|
||||
// Update markers on scroll/resize
|
||||
loadWatchStatus();
|
||||
updateHistoryButtons();
|
||||
const shell = document.querySelector('.document-shell');
|
||||
if (shell) {
|
||||
shell.addEventListener('scroll', () => requestAnimationFrame(updateStripMarkers));
|
||||
}
|
||||
shell?.addEventListener('scroll', () => requestAnimationFrame(updateStripMarkers));
|
||||
window.addEventListener('resize', () => requestAnimationFrame(updateStripMarkers));
|
||||
// Initial marker update after layout settles
|
||||
setTimeout(updateStripMarkers, 100);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
|
||||
else init();
|
||||
})();
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
const trigger = bell.querySelector('.notification-bell__trigger');
|
||||
const countEl = bell.querySelector('[data-unread-count]');
|
||||
const dropdown = bell.querySelector('[data-notification-dropdown]');
|
||||
if (!trigger || !countEl || !dropdown) return;
|
||||
let isOpen = false;
|
||||
|
||||
trigger.addEventListener('click', (e) => {
|
||||
@@ -76,31 +77,42 @@
|
||||
notifications.forEach(n => {
|
||||
const li = document.createElement('li');
|
||||
li.className = n.readAt ? 'notification-item is-read' : 'notification-item';
|
||||
li.innerHTML = `
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'notification-item__button';
|
||||
button.innerHTML = `
|
||||
<div class="notification-message">${escapeHtml(n.message)}</div>
|
||||
<time class="notification-time">${formatDate(n.createdAt)}</time>
|
||||
`;
|
||||
if (!n.readAt) {
|
||||
li.addEventListener('click', () => markRead(n.id));
|
||||
}
|
||||
button.addEventListener('click', async () => {
|
||||
if (!n.readAt) await markRead(n.id, false);
|
||||
const target = notificationURL(n);
|
||||
if (target) window.location.assign(target);
|
||||
});
|
||||
li.appendChild(button);
|
||||
list.appendChild(li);
|
||||
});
|
||||
|
||||
// Add "Mark all read" button
|
||||
const footer = document.createElement('li');
|
||||
footer.className = 'notification-footer';
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = 'Mark all read';
|
||||
btn.addEventListener('click', markAllRead);
|
||||
footer.appendChild(btn);
|
||||
list.appendChild(footer);
|
||||
if (notifications.some(notification => !notification.readAt)) {
|
||||
const footer = document.createElement('li');
|
||||
footer.className = 'notification-footer';
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = 'Mark all read';
|
||||
btn.addEventListener('click', markAllRead);
|
||||
footer.appendChild(btn);
|
||||
list.appendChild(footer);
|
||||
}
|
||||
}
|
||||
|
||||
async function markRead(id) {
|
||||
async function markRead(id, refresh = true) {
|
||||
try {
|
||||
await fetch(`/api/notifications/${id}/read`, { method: 'POST' });
|
||||
updateBell();
|
||||
loadNotifications();
|
||||
const response = await fetch(`/api/notifications/${encodeURIComponent(id)}/read`, { method: 'POST' });
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
if (refresh) {
|
||||
updateBell();
|
||||
loadNotifications();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('mark read', err);
|
||||
}
|
||||
@@ -108,7 +120,8 @@
|
||||
|
||||
async function markAllRead() {
|
||||
try {
|
||||
await fetch('/api/notifications/read-all', { method: 'POST' });
|
||||
const response = await fetch('/api/notifications/read-all', { method: 'POST' });
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
updateBell();
|
||||
loadNotifications();
|
||||
} catch (err) {
|
||||
@@ -127,6 +140,12 @@
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
function notificationURL(notification) {
|
||||
if (notification.resourceType !== 'document' || !notification.resourceId) return '';
|
||||
const path = notification.resourceId.replace(/^doc:/, '').split('/').map(encodeURIComponent).join('/');
|
||||
return '/docs/' + path;
|
||||
}
|
||||
|
||||
// Listen for WebSocket notification events
|
||||
if (window.__cairnquireRealtime) {
|
||||
window.__cairnquireRealtime.on('notification', () => {
|
||||
|
||||
@@ -198,19 +198,27 @@ code {
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.notification-item__button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.notification-item:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.notification-item.is-read {
|
||||
opacity: 0.7;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.notification-message {
|
||||
@@ -1199,6 +1207,27 @@ code {
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.comments-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0.45rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.comments-toolbar button,
|
||||
.comment-action {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.comments-toolbar button {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Bidirectional hover highlighting */
|
||||
.comments-aside-item {
|
||||
margin-bottom: 0.5rem;
|
||||
@@ -1216,6 +1245,13 @@ code {
|
||||
}
|
||||
|
||||
.comments-aside-item__target {
|
||||
display: block;
|
||||
width: calc(100% + 1.2rem);
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
border-top: 0;
|
||||
border-right: 0;
|
||||
border-left: 0;
|
||||
margin: -0.6rem -0.6rem 0.4rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
@@ -1259,6 +1295,39 @@ code {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.comment-thread__replies {
|
||||
margin-left: 0.7rem;
|
||||
padding-left: 0.65rem;
|
||||
border-left: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.comment-thread.is-resolved {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.comment-status {
|
||||
padding: 0.05rem 0.3rem;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.comment-actions {
|
||||
display: flex;
|
||||
gap: 0.65rem;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.comment-action {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.comment-action:hover,
|
||||
.comments-toolbar button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.document-comments-aside__empty {
|
||||
margin: 0;
|
||||
padding: 1rem 0;
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
<script src="/static/cache.js" defer></script>
|
||||
<script src="/static/sync.js" defer></script>
|
||||
<script src="/static/realtime.js" defer></script>
|
||||
<script src="/static/notification-bell.js" defer></script>
|
||||
<script src="/static/editor.js" defer></script>
|
||||
<script src="/static/auth.js" defer></script>
|
||||
<script src="/static/render.js" defer></script>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<section class="workspace-shell workspace-shell--document">
|
||||
<div class="mobile-drawer-backdrop" data-mobile-drawer-backdrop aria-hidden="true"></div>
|
||||
{{ template "browser" .Browser }}
|
||||
<article class="document-shell" data-document-path="{{ .Path }}" data-document-hash="{{ .Hash }}" data-can-comment="{{ .CanComment }}">
|
||||
<article class="document-shell" data-document-id="{{ .ID }}" data-document-path="{{ .Path }}" data-document-hash="{{ .Hash }}" data-can-comment="{{ .CanComment }}">
|
||||
<div class="document-mobile-bar">
|
||||
<button class="document-mobile-bar__btn document-mobile-bar__btn--picker" type="button" data-toggle-picker aria-label="Open file picker" aria-expanded="false">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="6" x2="20" y2="6"></line><line x1="4" y1="12" x2="20" y2="12"></line><line x1="4" y1="18" x2="20" y2="18"></line></svg>
|
||||
@@ -43,6 +43,10 @@
|
||||
</svg>
|
||||
</summary>
|
||||
<div class="document-actions-dropdown__menu">
|
||||
<button class="document-actions-dropdown__item" type="button" data-document-watch aria-pressed="false">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"></path></svg>
|
||||
<span data-document-watch-label>Watch document</span>
|
||||
</button>
|
||||
{{ if .CanWrite }}
|
||||
<a class="document-actions-dropdown__item" href="/docs/{{ trimMd .Path }}/edit">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
|
||||
@@ -106,6 +110,9 @@
|
||||
<div class="document-comments-strip__track" data-comments-strip-track></div>
|
||||
</div>
|
||||
<div class="document-comments-panel">
|
||||
<div class="comments-toolbar">
|
||||
<button type="button" data-comments-history aria-pressed="false">Show resolved</button>
|
||||
</div>
|
||||
<div class="document-comments-panel__list" data-comments-aside-list></div>
|
||||
</div>
|
||||
|
||||
@@ -126,6 +133,9 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="document-comments-sheet__panel">
|
||||
<div class="comments-toolbar">
|
||||
<button type="button" data-comments-history aria-pressed="false">Show resolved</button>
|
||||
</div>
|
||||
<div class="document-comments-panel__list" data-comments-aside-list-mobile></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user