import {contrastColor} from '../utils/color.ts'; import {createSortable} from '../modules/sortable.ts'; import {GET, POST, request} from '../modules/fetch.ts'; import {hideFomanticModal} from '../modules/fomantic/modal.ts'; import {queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts'; import type {SortableEvent} from 'sortablejs'; import {toggleFullScreen} from '../utils.ts'; import {registerGlobalInitFunc} from '../modules/observer.ts'; import {localUserSettings} from '../modules/user-settings.ts'; import {UserEventsSharedWorker} from '../modules/worker.ts'; import {showInfoToast, showWarningToast} from '../modules/toast.ts'; // issueRef returns a short human label for a card, preferring the // rendered "#index" anchor text and falling back to the internal id. function issueRef(card: HTMLElement | null, issueID: number): string { const idx = card?.querySelector('.issue-card-title, .ref-issue, a[href*="/issues/"]')?.textContent?.trim(); const m = /#\d+/.exec(idx ?? ''); return m ? m[0] : `#${issueID}`; } function columnName(board: HTMLElement, columnID: number): string { const t = board.querySelector(`.project-column[data-id="${columnID}"] .project-column-title-text`)?.textContent?.trim(); return t || `column ${columnID}`; } const SESSION_TAG_HEADER = 'X-Session-Tag'; // sessionTag is generated once per page load. It is attached as the // X-Session-Tag header on every mutation request and compared against // incoming SSE payloads so the originating tab can suppress its own // echo (the source-of-truth DOM update already happened locally). let sessionTag = ''; function ensureSessionTag(): string { if (sessionTag) return sessionTag; if (globalThis.crypto?.randomUUID) { sessionTag = globalThis.crypto.randomUUID(); } else { sessionTag = `st-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`; } return sessionTag; } function withSessionTag(headers: HeadersInit | undefined): Headers { const h = new Headers(headers ?? {}); h.set(SESSION_TAG_HEADER, ensureSessionTag()); return h; } function updateIssueCount(card: HTMLElement): void { const parent = card.parentElement!; const count = parent.querySelectorAll('.issue-card').length; parent.querySelector('.project-column-issue-count')!.textContent = String(count); } async function moveIssue({item, from, to, oldIndex}: SortableEvent): Promise { const columnCards = to.querySelectorAll('.issue-card'); updateIssueCount(from); updateIssueCount(to); const columnSorting = { issues: Array.from(columnCards, (card, i) => ({ issueID: parseInt(card.getAttribute('data-issue')!), sorting: i, })), }; try { await POST(`${to.getAttribute('data-url')}/move`, { data: columnSorting, headers: withSessionTag(undefined), }); } catch (error) { console.error(error); if (oldIndex !== undefined) { from.insertBefore(item, from.children[oldIndex]); } } } async function initRepoProjectSortable(): Promise { // the HTML layout is: #project-board.board > .project-column .cards > .issue-card const mainBoard = document.querySelector('#project-board')!; let boardColumns = mainBoard.querySelectorAll('.project-column'); createSortable(mainBoard, { group: 'project-column', draggable: '.project-column', handle: '.project-column-header', delayOnTouchOnly: true, delay: 500, onSort: async () => { // eslint-disable-line @typescript-eslint/no-misused-promises boardColumns = mainBoard.querySelectorAll('.project-column'); const columnSorting = { columns: Array.from(boardColumns, (column, i) => ({ columnID: parseInt(column.getAttribute('data-id')!), sorting: i, })), }; try { await POST(mainBoard.getAttribute('data-url')!, { data: columnSorting, headers: withSessionTag(undefined), }); } catch (error) { console.error(error); } }, }); for (const boardColumn of boardColumns) { const boardCardList = boardColumn.querySelector('.cards')!; createSortable(boardCardList, { group: 'shared', onAdd: moveIssue, // eslint-disable-line @typescript-eslint/no-misused-promises onUpdate: moveIssue, // eslint-disable-line @typescript-eslint/no-misused-promises delayOnTouchOnly: true, delay: 500, }); } } function initRepoProjectColumnEdit(writableProjectBoard: Element): void { const elModal = document.querySelector('.ui.modal#project-column-modal-edit')!; const elForm = elModal.querySelector('form')!; const elColumnId = elForm.querySelector('input[name="id"]')!; const elColumnTitle = elForm.querySelector('input[name="title"]')!; const elColumnColor = elForm.querySelector('input[name="color"]')!; const attrDataColumnId = 'data-modal-project-column-id'; const attrDataColumnTitle = 'data-modal-project-column-title-input'; const attrDataColumnColor = 'data-modal-project-column-color-input'; // the "new" button is not in project board, so need to query from document queryElems(document, '.show-project-column-modal-edit', (el) => { el.addEventListener('click', () => { elColumnId.value = el.getAttribute(attrDataColumnId)!; elColumnTitle.value = el.getAttribute(attrDataColumnTitle)!; elColumnColor.value = el.getAttribute(attrDataColumnColor)!; elColumnColor.dispatchEvent(new Event('input', {bubbles: true})); // trigger the color picker }); }); elForm.addEventListener('submit', async (e) => { e.preventDefault(); const columnId = elColumnId.value; const actionBaseLink = elForm.getAttribute('data-action-base-link'); const formData = new FormData(elForm); const formLink = columnId ? `${actionBaseLink}/${columnId}` : `${actionBaseLink}/columns/new`; const formMethod = columnId ? 'PUT' : 'POST'; try { elForm.classList.add('is-loading'); await request(formLink, {method: formMethod, data: formData, headers: withSessionTag(undefined)}); if (!columnId) { window.location.reload(); // newly added column, need to reload the page return; } // update the newly saved column title and color in the project board (to avoid reload) const elEditButton = writableProjectBoard.querySelector(`.show-project-column-modal-edit[${attrDataColumnId}="${columnId}"]`)!; elEditButton.setAttribute(attrDataColumnTitle, elColumnTitle.value); elEditButton.setAttribute(attrDataColumnColor, elColumnColor.value); const elBoardColumn = writableProjectBoard.querySelector(`.project-column[data-id="${columnId}"]`)!; const elBoardColumnTitle = elBoardColumn.querySelector(`.project-column-title-text`)!; elBoardColumnTitle.textContent = elColumnTitle.value; if (elColumnColor.value) { const textColor = contrastColor(elColumnColor.value); elBoardColumn.style.setProperty('background', elColumnColor.value, 'important'); elBoardColumn.style.setProperty('color', textColor, 'important'); queryElemChildren(elBoardColumn, '.divider', (divider: HTMLElement) => divider.style.color = textColor); } else { elBoardColumn.style.removeProperty('background'); elBoardColumn.style.removeProperty('color'); queryElemChildren(elBoardColumn, '.divider', (divider: HTMLElement) => divider.style.removeProperty('color')); } hideFomanticModal(elModal); } finally { elForm.classList.remove('is-loading'); } }); } function initRepoProjectToggleFullScreen(elProjectsView: HTMLElement): void { const enterFullscreenBtn = document.querySelector('.screen-full'); const exitFullscreenBtn = document.querySelector('.screen-normal'); if (!enterFullscreenBtn || !exitFullscreenBtn) return; const settingKey = 'projects-view-options'; type ProjectsViewOptions = { fullScreen: boolean; }; const opts = localUserSettings.getJsonObject(settingKey, {fullScreen: false}); const toggleFullscreenState = (isFullScreen: boolean) => { toggleFullScreen(elProjectsView, isFullScreen); toggleElem(enterFullscreenBtn, !isFullScreen); toggleElem(exitFullscreenBtn, isFullScreen); opts.fullScreen = isFullScreen; localUserSettings.setJsonObject(settingKey, opts); }; enterFullscreenBtn.addEventListener('click', () => toggleFullscreenState(true)); exitFullscreenBtn.addEventListener('click', () => toggleFullscreenState(false)); if (opts.fullScreen) { // a temporary solution to remember the full screen state, not perfect, // just make UX better than before, especially for users who need to change the label filter frequently and want to keep full screen mode. toggleFullscreenState(true); } } // SSE handlers --------------------------------------------------------------- type EventPayloadBase = {session_tag?: string}; type CardMovedPayload = EventPayloadBase & { project_id: number; issue_id: number; from_column_id: number; to_column_id: number; sorting: number; }; type CardLinkedPayload = EventPayloadBase & { project_id: number; issue_id: number; column_id: number; }; type CardUnlinkedPayload = EventPayloadBase & { project_id: number; issue_id: number; }; type CardStateChangedPayload = EventPayloadBase & { project_id: number; issue_id: number; is_closed: boolean; }; type ColumnUpdatedPayload = { project_id: number; column_id: number; title: string; color: string; sorting: number; }; type ColumnDeletedPayload = { project_id: number; column_id: number; }; type ColumnReorderedPayload = { project_id: number; columns: Array<{column_id: number; sorting: number}>; }; type ProjectUpdatedPayload = { project_id: number; title: string; description: string; card_type: string; is_closed: boolean; }; // columnIssuesURL builds the appropriate "list issues for column" API // path for the current page scope. Server-side these endpoints all // return the same JSON shape; the frontend just needs the right base. function columnIssuesURL(board: HTMLElement, columnID: number): string | null { const projectID = board.getAttribute('data-project-id'); const scope = board.getAttribute('data-project-scope'); const owner = board.getAttribute('data-project-owner'); const repo = board.getAttribute('data-project-repo'); const {appSubUrl} = window.config; if (!projectID || !owner) return null; if (scope === 'repo' && repo) { return `${appSubUrl}/api/v1/repos/${owner}/${repo}/projects/${projectID}/columns/${columnID}/issues`; } if (scope === 'org') { return `${appSubUrl}/api/v1/orgs/${owner}/projects/${projectID}/columns/${columnID}/issues`; } return `${appSubUrl}/api/v1/users/${owner}/projects/${projectID}/columns/${columnID}/issues`; } function updateColumnCount(columnEl: HTMLElement): void { const cards = columnEl.querySelectorAll('.issue-card').length; const badge = columnEl.querySelector('.project-column-issue-count'); if (badge) badge.textContent = String(cards); } function handleCardMoved(board: HTMLElement, payload: CardMovedPayload): void { if (payload.session_tag && payload.session_tag === sessionTag) return; const card = board.querySelector(`.issue-card[data-issue="${payload.issue_id}"]`); if (!card) { // Card is not currently rendered (filtered out, or new since // page load). A targeted column re-fetch is the safe fallback. refetchColumn(board, payload.to_column_id); showInfoToast(`#${payload.issue_id} → ${columnName(board, payload.to_column_id)}`); return; } const ref = issueRef(card, payload.issue_id); const target = board.querySelector(`#board_${payload.to_column_id}`); if (!target) return; const fromColumn = card.parentElement; target.append(card); if (fromColumn instanceof HTMLElement) { const fromColumnEl = fromColumn.closest('.project-column'); if (fromColumnEl) updateColumnCount(fromColumnEl); } const toColumnEl = target.closest('.project-column'); if (toColumnEl) updateColumnCount(toColumnEl); showInfoToast(`${ref} → ${columnName(board, payload.to_column_id)}`); } async function refetchColumn(board: HTMLElement, columnID: number): Promise { const url = columnIssuesURL(board, columnID); if (!url) return; try { const resp = await GET(url); if (!resp.ok) return; // Response shape: list of API issues; we don't have a templated // card render available client-side, so we just refresh the // column count badge here. The DOM-level reorder/insert is // delivered by the matching CardMoved/CardUnlinked events. const issues = await resp.json(); const target = board.querySelector(`#board_${columnID}`); if (!target) return; const colEl = target.closest('.project-column'); if (colEl) { const badge = colEl.querySelector('.project-column-issue-count'); if (badge) badge.textContent = String(Array.isArray(issues) ? issues.length : 0); } } catch (error) { console.error(error); } } function handleCardLinked(board: HTMLElement, payload: CardLinkedPayload): void { if (payload.session_tag && payload.session_tag === sessionTag) return; refetchColumn(board, payload.column_id); // no await showInfoToast(`#${payload.issue_id} added to ${columnName(board, payload.column_id)}`); } function handleCardUnlinked(board: HTMLElement, payload: CardUnlinkedPayload): void { if (payload.session_tag && payload.session_tag === sessionTag) return; const card = board.querySelector(`.issue-card[data-issue="${payload.issue_id}"]`); if (!card) return; const ref = issueRef(card, payload.issue_id); const colEl = card.closest('.project-column'); card.remove(); if (colEl) updateColumnCount(colEl); showInfoToast(`${ref} removed from board`); } function handleCardStateChanged(board: HTMLElement, payload: CardStateChangedPayload): void { if (payload.session_tag && payload.session_tag === sessionTag) return; const card = board.querySelector(`.issue-card[data-issue="${payload.issue_id}"]`); if (!card) return; // Flip the issue state octicon in place (matches templates/shared/issueicon.tmpl). // PR cards carry merged/draft variants we don't recompute here; the column // refetch below keeps state-filtered boards and counts correct regardless. const icon = card.querySelector('.issue-card-icon svg'); if (icon && !icon.classList.contains('octicon-git-pull-request')) { icon.classList.remove('octicon-issue-opened', 'octicon-issue-closed', 'tw-text-green', 'tw-text-red'); icon.classList.add( payload.is_closed ? 'octicon-issue-closed' : 'octicon-issue-opened', payload.is_closed ? 'tw-text-red' : 'tw-text-green', ); } const ref = issueRef(card, payload.issue_id); // The card's containing column is `#board_{columnID}` (its direct parent). const parent = card.parentElement; if (parent instanceof HTMLElement && parent.id.startsWith('board_')) { refetchColumn(board, Number(parent.id.slice('board_'.length))); } showInfoToast(`${ref} ${payload.is_closed ? 'closed' : 'reopened'}`); } function handleColumnCreated(): void { // Rare event; reload is cheap and avoids client-side template duplication. window.location.reload(); } function handleColumnUpdated(board: HTMLElement, payload: ColumnUpdatedPayload): void { const colEl = board.querySelector(`.project-column[data-id="${payload.column_id}"]`); if (!colEl) return; const titleEl = colEl.querySelector('.project-column-title-text'); const oldTitle = titleEl?.textContent?.trim(); if (titleEl) titleEl.textContent = payload.title; if (oldTitle && oldTitle !== payload.title) { showInfoToast(`Column renamed to “${payload.title}”`); } if (payload.color) { const textColor = contrastColor(payload.color); colEl.style.setProperty('background', payload.color, 'important'); colEl.style.setProperty('color', textColor, 'important'); queryElemChildren(colEl, '.divider', (divider: HTMLElement) => divider.style.color = textColor); } else { colEl.style.removeProperty('background'); colEl.style.removeProperty('color'); queryElemChildren(colEl, '.divider', (divider: HTMLElement) => divider.style.removeProperty('color')); } } function handleColumnDeleted(board: HTMLElement, payload: ColumnDeletedPayload): void { const colEl = board.querySelector(`.project-column[data-id="${payload.column_id}"]`); if (!colEl) return; const name = colEl.querySelector('.project-column-title-text')?.textContent?.trim(); colEl.remove(); showInfoToast(name ? `Column “${name}” removed` : 'A column was removed'); } function handleColumnReordered(board: HTMLElement, payload: ColumnReorderedPayload): void { // Sort the columns array by the new sorting value, then re-attach // each column element in that order. appendChild on an existing // node moves it rather than cloning, so the result is an in-place // reorder. const order = Array.from(payload.columns).sort((a, b) => a.sorting - b.sorting); for (const entry of order) { const el = board.querySelector(`.project-column[data-id="${entry.column_id}"]`); if (el) board.append(el); } } function handleProjectUpdated(payload: ProjectUpdatedPayload): void { const header = document.querySelector('.project-header h2'); if (header) header.textContent = payload.title; const desc = document.querySelector('.project-description .render-content'); if (desc) desc.textContent = payload.description; } function handleProjectDeleted(): void { // Best-effort: navigate up one path segment from the current URL. // The board lives at .../projects/{id}; the listing page is the // parent. Falling back to the homepage on any URL we don't // recognise is acceptable since this is a destructive event. // Show a sticky warning first and delay the redirect briefly so the // user understands why the page is about to change under them. showWarningToast('This project was deleted — returning to the project list'); const parts = window.location.pathname.split('/'); const dest = parts.length > 1 ? (parts.slice(0, -1).join('/') || '/') : '/'; setTimeout(() => { window.location.href = dest }, 1500); } // dispatchProjectEvent picks the right handler for an SSE payload. // The backend uses one event name per project but disambiguates event // types by payload shape; we sniff discriminating fields here. Order // matters: the more specific shapes are checked first. function dispatchProjectEvent(board: HTMLElement, payload: any): void { if ('issue_id' in payload && 'is_closed' in payload) { // CardStateChanged: must precede the CardUnlinked branch below, whose // "issue_id and no column_id" shape would otherwise swallow it and // wrongly remove the card on a close/reopen. handleCardStateChanged(board, payload as CardStateChangedPayload); } else if ('from_column_id' in payload && 'to_column_id' in payload) { handleCardMoved(board, payload as CardMovedPayload); } else if ('column_id' in payload && 'issue_id' in payload && 'project_id' in payload) { handleCardLinked(board, payload as CardLinkedPayload); } else if ('issue_id' in payload && !('column_id' in payload)) { handleCardUnlinked(board, payload as CardUnlinkedPayload); } else if ('columns' in payload) { handleColumnReordered(board, payload as ColumnReorderedPayload); } else if ('column_id' in payload && 'title' in payload) { handleColumnUpdated(board, payload as ColumnUpdatedPayload); if ('is_default' in payload) handleColumnCreated(); } else if ('column_id' in payload) { handleColumnDeleted(board, payload as ColumnDeletedPayload); } else if ('title' in payload && 'card_type' in payload) { handleProjectUpdated(payload as ProjectUpdatedPayload); } else if ('project_id' in payload && Object.keys(payload).length <= 2) { handleProjectDeleted(); } } function initRepoProjectSSE(elProjectsView: HTMLElement): void { const board = elProjectsView.querySelector('#project-board'); if (!board) return; const projectID = board.getAttribute('data-project-id'); if (!projectID) return; if (!window.EventSource || !window.SharedWorker) return; ensureSessionTag(); const eventName = `project-board.${projectID}`; let worker: UserEventsSharedWorker; try { worker = new UserEventsSharedWorker('project-board-worker'); } catch (error) { console.error('project board SSE: failed to start worker', error); return; } worker.addMessageEventListener((event: MessageEvent) => { if (!event.data || event.data.type !== eventName) return; let payload: any; try { payload = JSON.parse(event.data.data); } catch (error) { console.error('project board SSE: malformed payload', error, event.data); return; } dispatchProjectEvent(board, payload); }); worker.startPort(); // Subscribe to the per-project event name on top of the worker's // default listener set so the SharedWorker forwards us the events. worker.sharedWorker.port.postMessage({type: 'listen', eventType: eventName}); } export function initRepoProjectsView(): void { registerGlobalInitFunc('initRepoProjectsView', (elProjectsView) => { initRepoProjectToggleFullScreen(elProjectsView); initRepoProjectSSE(elProjectsView); const writableProjectBoard = document.querySelector('#project-board[data-project-board-writable="true"]'); if (!writableProjectBoard) return; initRepoProjectSortable(); // no await initRepoProjectColumnEdit(writableProjectBoard); }); }