480 lines
16 KiB
JavaScript

/**
* Docs Comments Widget — Gitea Issues-backed comments for MkDocs pages
*
* Loads approved comments from the Express API proxy, supports:
* - Anonymous comments (with moderation queue)
* - Gitea OAuth2 login for instant comments
* - Dark/light theme via MkDocs Material CSS vars
* - SPA re-init via document$.subscribe()
*/
(function () {
'use strict';
// --- Config ---
function getApiUrl() {
// env-config.js sets window.PAYMENT_API_URL to the resolved API URL
return window.PAYMENT_API_URL || 'http://localhost:4000';
}
var API_BASE = '';
var SESSION_KEY = 'docs-comment-gitea-token';
var USER_KEY = 'docs-comment-gitea-user';
var STATE_KEY = 'docs-comment-oauth-state';
var RETURN_KEY = 'docs-comment-return-url';
// --- Helpers ---
function escapeHtml(str) {
var div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
/**
* Minimal inline markdown: **bold**, *italic*, `code`, [text](url)
*/
function renderInlineMarkdown(text) {
var html = escapeHtml(text);
// Code (backticks)
html = html.replace(/`([^`]+)`/g, '<code class="dc-inline-code">$1</code>');
// Bold
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// Italic
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
// Links
html = html.replace(
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>'
);
// Newlines
html = html.replace(/\n/g, '<br>');
return html;
}
function timeAgo(dateStr) {
var now = Date.now();
var then = new Date(dateStr).getTime();
var diff = Math.floor((now - then) / 1000);
if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
if (diff < 2592000) return Math.floor(diff / 86400) + 'd ago';
return new Date(dateStr).toLocaleDateString();
}
function getInitials(name) {
return name
.split(/\s+/)
.map(function (w) { return w[0]; })
.join('')
.toUpperCase()
.slice(0, 2);
}
// --- Auth ---
function getToken() {
try { return sessionStorage.getItem(SESSION_KEY); } catch { return null; }
}
function getUser() {
try {
var data = sessionStorage.getItem(USER_KEY);
return data ? JSON.parse(data) : null;
} catch { return null; }
}
function setAuth(token, user) {
try {
sessionStorage.setItem(SESSION_KEY, token);
sessionStorage.setItem(USER_KEY, JSON.stringify(user));
} catch { /* sessionStorage unavailable */ }
}
function clearAuth() {
try {
sessionStorage.removeItem(SESSION_KEY);
sessionStorage.removeItem(USER_KEY);
} catch { /* ignore */ }
}
// --- API ---
function fetchComments(pagePath, page, callback) {
var url = API_BASE + '/api/docs-comments/comments?pagePath=' +
encodeURIComponent(pagePath) + '&page=' + (page || 1);
fetch(url)
.then(function (res) {
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
})
.then(function (data) { callback(null, data); })
.catch(function (err) { callback(err); });
}
function postAnonymous(payload, callback) {
fetch(API_BASE + '/api/docs-comments/comments/anonymous', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
.then(function (res) {
if (!res.ok) return res.json().then(function (d) { throw new Error(d.error || 'Error'); });
return res.json();
})
.then(function (data) { callback(null, data); })
.catch(function (err) { callback(err); });
}
function postAuthenticated(payload, token, callback) {
fetch(API_BASE + '/api/docs-comments/comments/authenticated', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Gitea-Token': token,
},
body: JSON.stringify(payload),
})
.then(function (res) {
if (!res.ok) return res.json().then(function (d) { throw new Error(d.error || 'Error'); });
return res.json();
})
.then(function (data) { callback(null, data); })
.catch(function (err) { callback(err); });
}
function fetchOAuthConfig(callback) {
fetch(API_BASE + '/api/docs-comments/oauth/config')
.then(function (res) { return res.json(); })
.then(function (data) { callback(null, data); })
.catch(function (err) { callback(err); });
}
function exchangeCode(code, redirectUri, callback) {
fetch(API_BASE + '/api/docs-comments/oauth/exchange', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: code, redirectUri: redirectUri }),
})
.then(function (res) {
if (!res.ok) throw new Error('OAuth exchange failed');
return res.json();
})
.then(function (data) { callback(null, data); })
.catch(function (err) { callback(err); });
}
// --- Render ---
function renderAvatar(comment) {
if (comment.avatarUrl) {
return '<img class="dc-avatar" src="' + escapeHtml(comment.avatarUrl) + '" alt="" loading="lazy">';
}
return '<span class="dc-avatar dc-avatar--initials">' + escapeHtml(getInitials(comment.authorName)) + '</span>';
}
function renderComment(comment) {
var fullDate = new Date(comment.createdAt).toLocaleString();
return (
'<div class="dc-comment">' +
'<div class="dc-comment__header">' +
renderAvatar(comment) +
'<span class="dc-comment__author">' + escapeHtml(comment.authorName) + '</span>' +
(comment.isAnonymous ? '<span class="dc-comment__badge">Guest</span>' : '') +
'<time class="dc-comment__time" title="' + escapeHtml(fullDate) + '">' + timeAgo(comment.createdAt) + '</time>' +
'</div>' +
'<div class="dc-comment__body">' + renderInlineMarkdown(comment.body) + '</div>' +
'</div>'
);
}
function renderCommentList(data) {
if (!data.comments || data.comments.length === 0) {
return '<p class="dc-empty">No comments yet. Be the first to share your thoughts!</p>';
}
return data.comments.map(renderComment).join('');
}
function renderForm(pagePath, oauthConfig) {
var token = getToken();
var user = getUser();
if (token && user) {
// Authenticated form
return (
'<div class="dc-form dc-form--authenticated">' +
'<div class="dc-form__user">' +
'<img class="dc-avatar" src="' + escapeHtml(user.avatarUrl || '') + '" alt="">' +
'<span>Commenting as <strong>' + escapeHtml(user.name) + '</strong></span>' +
'<button class="dc-btn dc-btn--text dc-logout-btn" type="button">Sign out</button>' +
'</div>' +
'<textarea class="dc-textarea dc-auth-body" placeholder="Write a comment... (Markdown supported)" rows="3"></textarea>' +
'<div class="dc-form__actions">' +
'<button class="dc-btn dc-btn--primary dc-submit-auth" type="button">Post Comment</button>' +
'</div>' +
'</div>'
);
}
// Anonymous form with optional OAuth login
var loginBtn = '';
if (oauthConfig && oauthConfig.oauthEnabled) {
loginBtn = '<button class="dc-btn dc-btn--outline dc-login-btn" type="button">Sign in with Gitea</button>';
}
return (
'<div class="dc-form dc-form--anonymous">' +
'<div class="dc-form__row">' +
'<input class="dc-input dc-anon-name" type="text" placeholder="Your name *" maxlength="100">' +
'<input class="dc-input dc-anon-email" type="email" placeholder="Email (optional)" maxlength="255">' +
'</div>' +
// Honeypot — hidden from humans
'<input class="dc-honeypot" type="text" name="website" tabindex="-1" autocomplete="off">' +
'<textarea class="dc-textarea dc-anon-body" placeholder="Write a comment... (Markdown supported, 10+ characters)" rows="3"></textarea>' +
'<div class="dc-form__actions">' +
'<button class="dc-btn dc-btn--primary dc-submit-anon" type="button">Post as Guest</button>' +
loginBtn +
'</div>' +
'<p class="dc-form__note">Guest comments are reviewed before appearing.</p>' +
'</div>'
);
}
// --- Main Init ---
function initWidget(container) {
var pagePath = container.getAttribute('data-page-path') || '';
if (!pagePath) return;
API_BASE = getApiUrl();
container.innerHTML =
'<div class="dc-widget">' +
'<h3 class="dc-title">Comments</h3>' +
'<div class="dc-comments-list dc-loading">Loading comments...</div>' +
'<div class="dc-form-container"></div>' +
'</div>';
var listEl = container.querySelector('.dc-comments-list');
var formContainer = container.querySelector('.dc-form-container');
var oauthConfig = null;
// Load OAuth config + comments in parallel
fetchOAuthConfig(function (err, config) {
if (!err && config) oauthConfig = config;
renderFormSection();
});
loadComments(1);
function loadComments(page) {
listEl.classList.add('dc-loading');
listEl.innerHTML = 'Loading comments...';
fetchComments(pagePath, page, function (err, data) {
listEl.classList.remove('dc-loading');
if (err) {
listEl.innerHTML = '<p class="dc-error">Comments unavailable.</p>';
return;
}
listEl.innerHTML = renderCommentList(data);
});
}
function renderFormSection() {
formContainer.innerHTML = renderForm(pagePath, oauthConfig);
bindFormEvents();
}
function bindFormEvents() {
// Anonymous submit
var submitAnon = formContainer.querySelector('.dc-submit-anon');
if (submitAnon) {
submitAnon.addEventListener('click', function () {
var name = formContainer.querySelector('.dc-anon-name').value.trim();
var email = formContainer.querySelector('.dc-anon-email').value.trim();
var body = formContainer.querySelector('.dc-anon-body').value.trim();
var honeypot = formContainer.querySelector('.dc-honeypot').value;
if (!name) { showFormError('Please enter your name.'); return; }
if (body.length < 10) { showFormError('Comment must be at least 10 characters.'); return; }
submitAnon.disabled = true;
submitAnon.textContent = 'Posting...';
postAnonymous(
{ pagePath: pagePath, authorName: name, authorEmail: email || undefined, body: body, website: honeypot },
function (err) {
submitAnon.disabled = false;
submitAnon.textContent = 'Post as Guest';
if (err) { showFormError(err.message || 'Failed to post comment.'); return; }
formContainer.querySelector('.dc-anon-body').value = '';
showFormSuccess('Your comment has been submitted for review.');
}
);
});
}
// Authenticated submit
var submitAuth = formContainer.querySelector('.dc-submit-auth');
if (submitAuth) {
submitAuth.addEventListener('click', function () {
var body = formContainer.querySelector('.dc-auth-body').value.trim();
var token = getToken();
if (body.length < 10) { showFormError('Comment must be at least 10 characters.'); return; }
if (!token) { showFormError('Session expired. Please sign in again.'); clearAuth(); renderFormSection(); return; }
submitAuth.disabled = true;
submitAuth.textContent = 'Posting...';
postAuthenticated(
{ pagePath: pagePath, body: body },
token,
function (err) {
submitAuth.disabled = false;
submitAuth.textContent = 'Post Comment';
if (err) {
if (err.message && err.message.indexOf('401') !== -1) {
clearAuth();
renderFormSection();
showFormError('Session expired. Please sign in again.');
return;
}
showFormError(err.message || 'Failed to post comment.');
return;
}
formContainer.querySelector('.dc-auth-body').value = '';
loadComments(1);
}
);
});
}
// OAuth login
var loginBtn = formContainer.querySelector('.dc-login-btn');
if (loginBtn && oauthConfig && oauthConfig.oauthEnabled) {
loginBtn.addEventListener('click', function () {
var state = Math.random().toString(36).slice(2);
try {
sessionStorage.setItem(STATE_KEY, state);
sessionStorage.setItem(RETURN_KEY, window.location.href);
} catch { /* ignore */ }
var redirectUri = window.location.origin + '/comments/callback/';
var url = oauthConfig.authorizeUrl +
'?client_id=' + encodeURIComponent(oauthConfig.clientId) +
'&redirect_uri=' + encodeURIComponent(redirectUri) +
'&response_type=code' +
'&state=' + encodeURIComponent(state);
window.location.href = url;
});
}
// Logout
var logoutBtn = formContainer.querySelector('.dc-logout-btn');
if (logoutBtn) {
logoutBtn.addEventListener('click', function () {
clearAuth();
renderFormSection();
});
}
}
function showFormError(msg) {
removeFormMessages();
var el = document.createElement('p');
el.className = 'dc-form-message dc-form-message--error';
el.textContent = msg;
formContainer.appendChild(el);
setTimeout(function () { el.remove(); }, 5000);
}
function showFormSuccess(msg) {
removeFormMessages();
var el = document.createElement('p');
el.className = 'dc-form-message dc-form-message--success';
el.textContent = msg;
formContainer.appendChild(el);
setTimeout(function () { el.remove(); }, 5000);
}
function removeFormMessages() {
var msgs = formContainer.querySelectorAll('.dc-form-message');
for (var i = 0; i < msgs.length; i++) msgs[i].remove();
}
}
// --- OAuth Callback Handler ---
function handleOAuthCallback() {
var params = new URLSearchParams(window.location.search);
var code = params.get('code');
var state = params.get('state');
if (!code) return;
// Verify state
var savedState;
try { savedState = sessionStorage.getItem(STATE_KEY); } catch { /* ignore */ }
if (savedState && state !== savedState) {
console.warn('[DocsComments] OAuth state mismatch');
return;
}
API_BASE = getApiUrl();
var redirectUri = window.location.origin + '/comments/callback/';
exchangeCode(code, redirectUri, function (err, data) {
if (err || !data || !data.accessToken) {
console.error('[DocsComments] OAuth exchange failed:', err);
return;
}
setAuth(data.accessToken, data.user);
// Redirect back to original page
var returnUrl;
try { returnUrl = sessionStorage.getItem(RETURN_KEY); } catch { /* ignore */ }
try {
sessionStorage.removeItem(STATE_KEY);
sessionStorage.removeItem(RETURN_KEY);
} catch { /* ignore */ }
window.location.href = returnUrl || '/';
});
}
// --- SPA Init ---
function init() {
// Check if this is the OAuth callback page
if (window.location.pathname.indexOf('/comments/callback') !== -1) {
handleOAuthCallback();
return;
}
var container = document.getElementById('docs-comments');
if (container) {
initWidget(container);
}
}
// MkDocs Material SPA navigation support
if (typeof document$ !== 'undefined') {
document$.subscribe(function () {
init();
});
} else {
// Fallback for non-SPA or non-Material builds
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
}
})();