diff --git a/apps/server/internal/httpserver/static/comments.js b/apps/server/internal/httpserver/static/comments.js
index b548c5c..325e1fe 100644
--- a/apps/server/internal/httpserver/static/comments.js
+++ b/apps/server/internal/httpserver/static/comments.js
@@ -7,7 +7,11 @@
const article = document.querySelector('[data-document-path]');
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;
@@ -165,60 +169,69 @@
updateStripMarkers();
}
- function renderCommentsAside() {
- if (!commentsAsideList) return;
- commentsAsideList.innerHTML = '';
+ 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 || '',
+ });
+ });
+ 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 = `
+
+ ${escapeHtml(c.content)}
+ `;
+ asideItem.appendChild(commentEl);
+ });
+
+ 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) {
- commentsAsideList.innerHTML = '';
+ container.innerHTML = '';
return;
}
commentables.forEach(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 || '',
- });
- });
- if (!comments.length) return;
-
- 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 = `
-
- ${escapeHtml(c.content)}
- `;
- asideItem.appendChild(commentEl);
- });
-
- commentsAsideList.appendChild(asideItem);
+ const item = buildAsideItemFor(el);
+ if (item) container.appendChild(item);
});
+ }
+ function renderCommentsAside() {
+ renderCommentsInto(commentsAsideList);
+ renderCommentsInto(commentsAsideListMobile);
}
function updateStripMarkers() {
@@ -326,6 +339,45 @@
return d.toLocaleString();
}
+ function commentsSheetOpen() {
+ return commentsAside?.classList.contains('is-open');
+ }
+
+ 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');
+ }
+ if (open) {
+ document.body.classList.add('drawer-open');
+ renderCommentsAside();
+ } else {
+ if (!pickerOpen() && !metaOpen()) {
+ document.body.classList.remove('drawer-open');
+ }
+ }
+ }
+
+ function pickerOpen() {
+ return document.querySelector('[data-picker-drawer]')?.classList.contains('is-open');
+ }
+
+ function metaOpen() {
+ return document.querySelector('[data-meta-drawer]')?.classList.contains('is-open');
+ }
+
+ if (commentsSheetToggle) {
+ commentsSheetToggle.addEventListener('click', () => {
+ setCommentsSheetOpen(!commentsSheetOpen());
+ });
+ }
+
+ if (commentsSheetClose) {
+ commentsSheetClose.addEventListener('click', () => setCommentsSheetOpen(false));
+ }
+
function init() {
const body = document.querySelector('.markdown-body');
if (!body) return;
diff --git a/apps/server/internal/httpserver/static/mobile.js b/apps/server/internal/httpserver/static/mobile.js
new file mode 100644
index 0000000..157c2cd
--- /dev/null
+++ b/apps/server/internal/httpserver/static/mobile.js
@@ -0,0 +1,77 @@
+(function () {
+ 'use strict';
+
+ const backdrop = document.querySelector('[data-mobile-drawer-backdrop]');
+ const pickerDrawer = document.querySelector('[data-picker-drawer]');
+ const metaDrawer = document.querySelector('[data-meta-drawer]');
+ const commentsAside = document.querySelector('[data-comments-aside]');
+ const pickerToggle = document.querySelector('[data-toggle-picker]');
+ const metaToggle = document.querySelector('[data-toggle-meta]');
+ const pickerClose = document.querySelector('[data-close-picker]');
+ const metaClose = document.querySelector('[data-close-meta]');
+
+ function anyDrawerOpen() {
+ return Boolean(
+ pickerDrawer?.classList.contains('is-open') ||
+ metaDrawer?.classList.contains('is-open') ||
+ commentsAside?.classList.contains('is-open')
+ );
+ }
+
+ function updateBackdrop() {
+ document.body.classList.toggle('drawer-open', anyDrawerOpen());
+ }
+
+ function setPickerOpen(open) {
+ if (!pickerDrawer) return;
+ pickerDrawer.classList.toggle('is-open', open);
+ if (pickerToggle) pickerToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
+ updateBackdrop();
+ }
+
+ function setMetaOpen(open) {
+ if (!metaDrawer) return;
+ metaDrawer.classList.toggle('is-open', open);
+ if (metaToggle) metaToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
+ updateBackdrop();
+ }
+
+ function closeAllDrawers() {
+ setPickerOpen(false);
+ setMetaOpen(false);
+ if (commentsAside) {
+ commentsAside.classList.remove('is-open');
+ const toggle = document.querySelector('[data-toggle-comments]');
+ if (toggle) toggle.setAttribute('aria-expanded', 'false');
+ }
+ updateBackdrop();
+ }
+
+ if (pickerToggle) {
+ pickerToggle.addEventListener('click', () => {
+ const willOpen = !pickerDrawer?.classList.contains('is-open');
+ closeAllDrawers();
+ setPickerOpen(willOpen);
+ });
+ }
+
+ if (metaToggle) {
+ metaToggle.addEventListener('click', () => {
+ const willOpen = !metaDrawer?.classList.contains('is-open');
+ closeAllDrawers();
+ setMetaOpen(willOpen);
+ });
+ }
+
+ if (pickerClose) pickerClose.addEventListener('click', () => setPickerOpen(false));
+ if (metaClose) metaClose.addEventListener('click', () => setMetaOpen(false));
+
+ if (backdrop) {
+ backdrop.addEventListener('click', closeAllDrawers);
+ }
+
+ // Close drawers on Escape key
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') closeAllDrawers();
+ });
+})();
diff --git a/apps/server/internal/httpserver/templates/base.gohtml b/apps/server/internal/httpserver/templates/base.gohtml
index 8888fbe..6f6268e 100644
--- a/apps/server/internal/httpserver/templates/base.gohtml
+++ b/apps/server/internal/httpserver/templates/base.gohtml
@@ -111,6 +111,7 @@
+
diff --git a/docker-compose.yml b/docker-compose.yml
index 4997c27..4189d21 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -4,10 +4,11 @@ services:
context: .
dockerfile: Dockerfile
ports:
- - "$PORT:$PORT"
+ - "${PORT:-8080}:${PORT:-8080}"
depends_on:
- mailpit
environment:
+ PORT: "${PORT:-8080}"
CAIRNQUIRE_DATABASE_PATH: "/workspace/data/db.sqlite"
CAIRNQUIRE_CONTENT_SOURCE_DIR: "/workspace/content"
CAIRNQUIRE_CONTENT_STORE_DIR: "/workspace/data/files"
@@ -20,7 +21,7 @@ services:
- cairnquire_data:/workspace/data
- ./content:/workspace/content
healthcheck:
- test: ["CMD", "curl", "--fail", "http://127.0.0.1:${PORT}/healthz"]
+ test: ["CMD", "curl", "--fail", "http://127.0.0.1:${PORT:-8080}/healthz"]
interval: 10s
timeout: 3s
retries: 5