repos / pgit

Improved static site generator for git repos
git clone https://github.com/xplshn/pgit.git

pgit / static
[CI] pgitBot  ·  2026-02-26

pgit.js

JavaScript
  1document.addEventListener('DOMContentLoaded', () => {
  2    if (!document.querySelector('.issue-container') && !document.getElementById('new-issue-btn')) {
  3        return;
  4    }
  5
  6    // --- GLOBAL STATE ---
  7    let isEditing = false; // Mutex to prevent multiple simultaneous edits
  8    let confirmCallback = null;
  9
 10    // --- ELEMENTS ---
 11    const aliasBtn = document.getElementById('alias-btn');
 12    const aliasModal = document.getElementById('alias-modal');
 13    const reactionPicker = document.getElementById('reaction-picker');
 14    const replyTextarea = document.getElementById('reply-textarea');
 15    const submitCommentBtn = document.getElementById('submit-comment-btn');
 16    const newIssueBtn = document.getElementById('new-issue-btn');
 17    const currentUserAliasSpan = document.getElementById('current-user-alias');
 18    const newIssueModal = document.getElementById('new-issue-modal');
 19    const submitNewIssueBtn = document.getElementById('submit-new-issue-btn');
 20    const issueFilterInput = document.getElementById('issue-filter-input');
 21    const issuesList = document.getElementById('issues-list');
 22
 23    // --- INITIALIZATION ---
 24    updateAliasDisplay();
 25    if (reactionPicker) {
 26        populateReactionPicker();
 27    }
 28
 29    // --- EVENT LISTENERS ---
 30    document.body.addEventListener('click', handleBodyClick);
 31    if (aliasBtn) aliasBtn.addEventListener('click', showAliasModal);
 32    if (submitCommentBtn) submitCommentBtn.addEventListener('click', handleSubmitNewComment);
 33    if (newIssueBtn) newIssueBtn.addEventListener('click', showNewIssueModal);
 34    if (submitNewIssueBtn) submitNewIssueBtn.addEventListener('click', handleSubmitNewIssue);
 35    if (issueFilterInput && issuesList) {
 36        issueFilterInput.addEventListener('input', filterIssues);
 37    }
 38
 39    // --- MODAL & POPUP HANDLING ---
 40    function handleBodyClick(e) {
 41        if (e.target.classList.contains('close-btn') || e.target.classList.contains('modal')) {
 42            const modalId = e.target.dataset.modal || (e.target.closest('.modal')?.id);
 43            if (modalId) {
 44                document.getElementById(modalId).style.display = 'none';
 45            }
 46        }
 47        if (e.target.id === 'save-alias-btn') {
 48            saveAlias();
 49        }
 50        const actionBtn = e.target.closest('.action-btn');
 51        if (actionBtn) {
 52            handleTimelineAction(actionBtn);
 53        }
 54        if (e.target.parentElement?.id === 'reaction-picker') {
 55            handleReactionSelection(e.target);
 56        }
 57        const reactionBadge = e.target.closest('.reaction-badge[data-action="unreact"]');
 58        if (reactionBadge) {
 59            handleUnreact(reactionBadge);
 60        }
 61        if (e.target.classList.contains('dropdown-toggle')) {
 62            e.preventDefault();
 63            toggleDropdown(e.target);
 64        } else if (!e.target.closest('.dropdown')) {
 65            document.querySelectorAll('.dropdown-menu').forEach(menu => menu.style.display = 'none');
 66        }
 67        if (e.target.classList.contains('mark-as-btn')) {
 68            e.preventDefault();
 69            handleMarkAs(e.target);
 70        }
 71        if (e.target.id === 'confirm-modal-ok-btn') {
 72            if (typeof confirmCallback === 'function') {
 73                confirmCallback();
 74            }
 75            document.getElementById('confirm-modal').style.display = 'none';
 76            confirmCallback = null;
 77        }
 78        if (e.target.id === 'confirm-modal-cancel-btn' || e.target.dataset.modal === 'confirm-modal') {
 79             document.getElementById('confirm-modal').style.display = 'none';
 80             confirmCallback = null;
 81        }
 82        if (reactionPicker && !reactionPicker.contains(e.target) && !e.target.matches('[data-action="react"]')) {
 83            reactionPicker.style.display = 'none';
 84        }
 85    }
 86
 87    function toggleDropdown(button) {
 88        const dropdownMenu = button.nextElementSibling;
 89        const isVisible = dropdownMenu.style.display === 'block';
 90        document.querySelectorAll('.dropdown-menu').forEach(menu => menu.style.display = 'none');
 91        dropdownMenu.style.display = isVisible ? 'none' : 'block';
 92    }
 93
 94    function showAliasModal() {
 95        if (aliasModal) {
 96            document.getElementById('alias-input').value = getCookie('pgit_alias') || '';
 97            aliasModal.style.display = 'block';
 98        }
 99    }
100
101    function showNewIssueModal(e) {
102        e.preventDefault();
103        if (newIssueModal) {
104            newIssueModal.style.display = 'block';
105        }
106    }
107
108    function showErrorModal(message) {
109        const modal = document.getElementById('error-modal');
110        if (modal) {
111            modal.querySelector('p').textContent = message;
112            modal.style.display = 'block';
113        } else {
114            console.error(message);
115        }
116    }
117
118    function showConfirmModal(message, onConfirm) {
119        const modal = document.getElementById('confirm-modal');
120        if (modal) {
121            modal.querySelector('#confirm-modal-message').textContent = message;
122            confirmCallback = onConfirm;
123            modal.style.display = 'block';
124        }
125    }
126
127    function showReactionPicker(button) {
128        if (!reactionPicker) return;
129        const rect = button.getBoundingClientRect();
130        reactionPicker.style.top = `${window.scrollY + rect.bottom}px`;
131        reactionPicker.style.left = `${window.scrollX + rect.left}px`;
132        reactionPicker.style.display = 'block';
133        const timelineItem = button.closest('.timeline-item');
134        reactionPicker.dataset.issueId = timelineItem.dataset.id;
135        reactionPicker.dataset.commentId = timelineItem.dataset.type === 'comment' ? timelineItem.dataset.id : '';
136        reactionPicker.dataset.type = timelineItem.dataset.type;
137    }
138
139    // --- CORE LOGIC ---
140    function handleTimelineAction(button) {
141        const action = button.dataset.action;
142        const item = button.closest('.timeline-item');
143        if (!item) return;
144
145        switch (action) {
146            case 'edit':
147                startEdit(item);
148                break;
149            case 'quote':
150                quoteText(item);
151                break;
152            case 'react':
153                showReactionPicker(button);
154                break;
155        }
156    }
157
158    function handleMarkAs(button) {
159        if (!PGB_EMAIL_ADDR) {
160            showErrorModal('Cannot perform this action: The repository contact email is not configured.');
161            return;
162        }
163        const issueId = button.dataset.issueId;
164        const status = button.dataset.status;
165
166        const subject = `${PGB_SUBJECT_TAG} Mark Issue #${issueId} as ${status}`;
167        const mailBody = `---pgitBot---\ncommand: mark-as\nissue-id: ${issueId}\nstatus: ${status}\n---pgitBot---`;
168        const mailto = `mailto:${PGB_EMAIL_ADDR}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(mailBody)}`;
169
170        window.location.href = mailto;
171    }
172
173    function handleSubmitNewComment() {
174        const issueId = document.querySelector('.timeline-item[data-type="issue"]').dataset.id;
175        const body = replyTextarea.value;
176        if (!body.trim()) return;
177
178        const subject = `${PGB_SUBJECT_TAG} New Comment on Issue #${issueId}`;
179        const mailBody = `${body}\n\n---pgitBot---\ncommand: add-comment\nissue-id: ${issueId}\n---pgitBot---`;
180        const mailto = `mailto:${PGB_EMAIL_ADDR}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(mailBody)}`;
181
182        window.location.href = mailto;
183        replyTextarea.value = '';
184    }
185
186    function handleSubmitNewIssue() {
187        const titleInput = document.getElementById('new-issue-title');
188        const bodyInput = document.getElementById('new-issue-body');
189        const title = titleInput.value.trim();
190        const body = bodyInput.value.trim();
191
192        if (!title) {
193            titleInput.style.borderColor = 'red';
194            return;
195        }
196        titleInput.style.borderColor = '';
197
198        const subject = `${PGB_SUBJECT_TAG} New Issue: ${title}`;
199        const mailBody = `${body}\n\n---pgitBot---\ncommand: create-issue\ntitle: ${title}\n---pgitBot---`;
200        const mailto = `mailto:${PGB_EMAIL_ADDR}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(mailBody)}`;
201
202        window.location.href = mailto;
203
204        titleInput.value = '';
205        bodyInput.value = '';
206        if (newIssueModal) {
207            newIssueModal.style.display = 'none';
208        }
209    }
210
211    function startEdit(item) {
212        if (isEditing) {
213            showErrorModal("Please save or cancel your current edit before editing another item.");
214            return;
215        }
216
217        const author = item.dataset.author;
218        if (getCookie('pgit_author_email') !== author) {
219            showConfirmModal("You are trying to edit an item you may not have created. The bot will reject this if you are not the original author. Continue?", () => proceedWithEdit(item));
220        } else {
221            proceedWithEdit(item);
222        }
223    }
224
225    function proceedWithEdit(item) {
226        isEditing = true;
227
228        const bodyElement = item.querySelector('.comment-body');
229        const originalMarkdownElement = item.querySelector('.original-markdown');
230        const actionsElement = item.querySelector('.comment-actions');
231
232        bodyElement.style.display = 'none';
233        actionsElement.style.display = 'none';
234
235        const editorTextarea = document.createElement('textarea');
236        editorTextarea.className = 'comment-editor';
237        editorTextarea.value = originalMarkdownElement.textContent;
238        item.querySelector('.comment-box').insertBefore(editorTextarea, bodyElement.nextSibling);
239        editorTextarea.focus();
240
241        const controlsDiv = document.createElement('div');
242        controlsDiv.className = 'comment-edit-controls';
243
244        const saveBtn = document.createElement('button');
245        saveBtn.className = 'btn';
246        saveBtn.textContent = 'Save';
247
248        const cancelBtn = document.createElement('button');
249        cancelBtn.className = 'btn btn-secondary';
250        cancelBtn.textContent = 'Cancel';
251
252        controlsDiv.appendChild(cancelBtn);
253        controlsDiv.appendChild(saveBtn);
254        editorTextarea.insertAdjacentElement('afterend', controlsDiv);
255
256        const cancelEdit = () => {
257            editorTextarea.remove();
258            controlsDiv.remove();
259            bodyElement.style.display = 'block';
260            actionsElement.style.display = 'flex';
261            isEditing = false;
262        };
263
264        cancelBtn.addEventListener('click', cancelEdit);
265
266        saveBtn.addEventListener('click', () => {
267            const newBody = editorTextarea.value;
268            if (!newBody.trim()) return;
269
270            const type = item.dataset.type;
271            const id = item.dataset.id;
272            const issueId = (type === 'issue') ? id : item.closest('.issue-container').querySelector('.timeline-item[data-type="issue"]').dataset.id;
273
274            const subject = `${PGB_SUBJECT_TAG} Edit ${type} on Issue #${issueId}`;
275            let mailBody = `${newBody}\n\n---pgitBot---\ncommand: edit\nissue-id: ${issueId}\ntype: ${type}\n`;
276            if (type === 'comment') {
277                mailBody += `comment-id: ${id}\n`;
278            }
279            mailBody += `---pgitBot---`;
280            const mailto = `mailto:${PGB_EMAIL_ADDR}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(mailBody)}`;
281            window.location.href = mailto;
282
283            cancelEdit();
284        });
285    }
286
287    function quoteText(item) {
288        if (!replyTextarea) return;
289        const originalMarkdownElement = item.querySelector('.original-markdown');
290        const author = item.querySelector('.author strong').textContent;
291        const selection = window.getSelection().toString();
292        const textToQuote = selection || originalMarkdownElement.textContent;
293
294        const quotedText = `> ${textToQuote.replace(/\n/g, '\n> ')}\n\n*Quoting ${author}*\n\n`;
295
296        replyTextarea.value = quotedText + replyTextarea.value;
297        replyTextarea.focus();
298        replyTextarea.setSelectionRange(replyTextarea.value.length, replyTextarea.value.length);
299    }
300
301    function handleReactionSelection(target) {
302        const reactionName = target.dataset.reaction;
303        if (!reactionName || !reactionPicker) return;
304
305        const issueId = reactionPicker.dataset.issueId;
306        const commentId = reactionPicker.dataset.commentId;
307        const type = reactionPicker.dataset.type;
308
309        const subject = `${PGB_SUBJECT_TAG} React to ${type} on Issue #${issueId}`;
310        let mailBody = `---pgitBot---\ncommand: react\nissue-id: ${issueId}\ntype: ${type}\nreaction: ${reactionName}\n`;
311        if (type === 'comment') {
312            mailBody += `comment-id: ${commentId}\n`;
313        }
314        mailBody += `---pgitBot---`;
315
316        const mailto = `mailto:${PGB_EMAIL_ADDR}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(mailBody)}`;
317        window.location.href = mailto;
318
319        reactionPicker.style.display = 'none';
320    }
321
322    function handleUnreact(button) {
323        const reactionName = button.dataset.reactionName;
324        if (!reactionName) return;
325
326        const timelineItem = button.closest('.timeline-item');
327        if (!timelineItem) return;
328
329        showConfirmModal(`Send email to remove your "${reactionName}" reaction?`, () => {
330            const type = timelineItem.dataset.type;
331            const issueId = (type === 'issue') ? timelineItem.dataset.id : button.closest('.issue-container').querySelector('.timeline-item[data-type="issue"]').dataset.id;
332            const commentId = (type === 'comment') ? timelineItem.dataset.id : '';
333
334            const subject = `${PGB_SUBJECT_TAG} Un-react to ${type} on Issue #${issueId}`;
335            let mailBody = `---pgitBot---\ncommand: unreact\nissue-id: ${issueId}\ntype: ${type}\nreaction: ${reactionName}\n`;
336            if (type === 'comment') {
337                mailBody += `comment-id: ${commentId}\n`;
338            }
339            mailBody += `---pgitBot---`;
340
341            const mailto = `mailto:${PGB_EMAIL_ADDR}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(mailBody)}`;
342            window.location.href = mailto;
343        });
344    }
345
346    function saveAlias() {
347        const aliasInput = document.getElementById('alias-input');
348        const newAlias = aliasInput.value.trim();
349
350        const subject = `${PGB_SUBJECT_TAG} Set Alias`;
351        const command = newAlias === '' ? 'unalias' : 'alias';
352        let mailBody = `---pgitBot---\ncommand: ${command}\n`;
353        if (command === 'alias') {
354            mailBody += `alias: ${newAlias}\n`;
355        }
356        mailBody += `---pgitBot---`;
357
358        const mailto = `mailto:${PGB_EMAIL_ADDR}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(mailBody)}`;
359        window.location.href = mailto;
360
361        setCookie('pgit_alias', newAlias || 'anonymous', 365);
362        updateAliasDisplay();
363        if (aliasModal) aliasModal.style.display = 'none';
364    }
365
366    function updateAliasDisplay() {
367        if (currentUserAliasSpan) {
368            currentUserAliasSpan.textContent = `Commenting as ${getCookie('pgit_alias') || 'anonymous'}`;
369        }
370    }
371
372    function populateReactionPicker() {
373        if (!reactionPicker) return;
374        let html = '';
375        for (const name in PGB_REACTIONS) {
376            html += `<button class="reaction-picker-emoji" data-reaction="${name}" title="${name}">${PGB_REACTIONS[name]}</button>`;
377        }
378        reactionPicker.innerHTML = html;
379    }
380
381    function setCookie(name, value, days) {
382        let expires = "";
383        if (days) {
384            const date = new Date();
385            date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
386            expires = "; expires=" + date.toUTCString();
387        }
388        document.cookie = name + "=" + (value || "") + expires + "; path=/; SameSite=Lax";
389    }
390
391    function getCookie(name) {
392        const nameEQ = name + "=";
393        const ca = document.cookie.split(';');
394        for (let i = 0; i < ca.length; i++) {
395            let c = ca[i];
396            while (c.charAt(0) == ' ') c = c.substring(1, c.length);
397            if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length);
398        }
399        return null;
400    }
401
402    function filterIssues() {
403        const filterText = issueFilterInput.value.toLowerCase();
404        const issueItems = issuesList.querySelectorAll('.issue-list-item');
405        issueItems.forEach(item => {
406            const title = item.dataset.issueTitle.toLowerCase();
407            if (title.includes(filterText)) {
408                item.style.display = 'flex';
409            } else {
410                item.style.display = 'none';
411            }
412        });
413    }
414});