1807 lines
90 KiB
HTML
1807 lines
90 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>Stackchain Dashboard</title>
|
||
<style>
|
||
:root { color-scheme: dark; --bg:#050c15; --panel:#0b1526; --line:#1b2d45; --text:#e5e7eb; --accent:#60a5fa; }
|
||
* { box-sizing: border-box; }
|
||
html, body { height: 100%; margin: 0; background: var(--bg); color: var(--text); font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; overflow-x: hidden; }
|
||
#bg { position: fixed; inset: 0; z-index: 0; pointer-events: none; opacity: .35; }
|
||
header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex; gap:16px; align-items:center; justify-content:space-between; background: linear-gradient(180deg, rgba(11,21,38,.95), rgba(11,21,38,.55), transparent); backdrop-filter: blur(4px); border-bottom: 1px solid #1b2d45; }
|
||
.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
|
||
button:hover { filter: brightness(1.15); }
|
||
.panel { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); }
|
||
.panel > summary { cursor: pointer; list-style-position: inside; }
|
||
.panel > summary h2 { display: inline-block; margin-left: 4px; }
|
||
.panel[open] > summary { margin-bottom: 8px; }
|
||
aside, section { position: relative; z-index: 1; }
|
||
main { position: relative; z-index: 1; display: grid; grid-template-columns: 300px 1fr 340px; gap: 14px; padding: 14px; }
|
||
@media (max-width: 1100px) { main { grid-template-columns: 1fr; } }
|
||
.sidebar { display:flex; flex-direction:column; gap: 12px; }
|
||
.stack { display:flex; flex-direction:column; gap:10px; }
|
||
h2 { font-size: 13px; margin: 8px 0; text-transform: uppercase; letter-spacing: .08em; color: #94a3b8; }
|
||
a { color: #60a5fa; text-decoration: none; }
|
||
.muted { color: #94a3b8; }
|
||
.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 10px; }
|
||
.kv .label { color: #94a3b8; }
|
||
.kv .value { color: #e5e7eb; }
|
||
.suggestion { padding: 10px; border-radius: 12px; border: 1px solid #1b2d45; background: #0f1d33; margin-bottom: 8px; animation: fadein .35s ease; }
|
||
.suggestion.high { border-color: #fca5a5; background: #2a1216; }
|
||
.suggestion.medium { border-color: #fcd34d; background: #2a2012; }
|
||
.suggestion.low { border-color: #d1fae5; background: #122a1e; }
|
||
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items:center; }
|
||
.pill { display:inline-block; padding: 2px 8px; border-radius: 999px; background:#0f2237; color:#9ab3d4; border:1px solid #1f3a5f; font-size: 12px; }
|
||
canvas { display: block; width: 100%; height: 260px; background: linear-gradient(180deg,#0a1525,#060c16); border: 1px solid #1f3a5f; border-radius: 10px; }
|
||
pre { margin: 0; padding: 8px; background: #0b1220; color: #dbe7f2; border-radius: 8px; overflow: auto; max-height: 220px; font-size: 12px; border:1px solid #1f3a5f }
|
||
input[type=\"text\"], textarea { width: 100%; padding: 8px; border-radius: 8px; border: 1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||
textarea { resize: vertical; min-height: 120px; }
|
||
#cmd-palette { position: fixed; left: 50%; top: 10%; transform: translateX(-50%); width: min(900px, 94vw); background: rgba(11,21,38,.96); border: 1px solid #2a496e; border-radius: 12px; box-shadow: 0 20px 70px rgba(0,0,0,.55); padding: 10px; z-index: 30; display: none; backdrop-filter: blur(12px); }
|
||
#cmd-palette.open { display: block; }
|
||
.cmd-item { padding: 10px; cursor: pointer; border-radius: 10px; color:#e5e7eb; }
|
||
.cmd-item:hover { background: #10233a; }
|
||
#whiteboard-modal, #markdown-modal { position: fixed; inset: 0; background: rgba(5,12,21,.55); display: none; align-items: center; justify-content: center; z-index: 40; backdrop-filter: blur(6px); }
|
||
#whiteboard-modal.open, #markdown-modal.open { display: flex; }
|
||
.modal { background: #0b1526; border: 1px solid #2a496e; border-radius: 14px; padding: 14px; width: min(1100px, 94vw); max-height: 92vh; overflow: auto; }
|
||
.modal-actions { display: flex; gap: 10px; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||
.badge { font-size: 11px; padding: 4px 8px; border-radius: 999px; background:#0f2237; color:#9ab3d4; border:1px solid #1f3a5f; }
|
||
.small { font-size: 12px; color: #94a3b8; }
|
||
.status { display:inline-flex; gap:6px; align-items:center; }
|
||
.dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 8px #22c55e; }
|
||
.widget { border: 1px solid #1b2d45; border-radius: 12px; padding: 10px; background: linear-gradient(180deg,#0f1d33,#0b1526); }
|
||
.widget h3 { margin: 4px 0 8px; font-size: 13px; color: #7aa1c9; }
|
||
.event { padding: 8px 0; border-bottom: 1px solid #1b2d45; }
|
||
.event:last-child { border-bottom: 0; }
|
||
.my-work { grid-column: 1 / -1; }
|
||
.my-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
|
||
.work-filters { display:flex; gap:8px; flex-wrap:wrap; }
|
||
.work-filter { min-height: 44px; }
|
||
.work-filter[aria-pressed="true"] { border-color:var(--accent); background:#1d4f7a; }
|
||
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
|
||
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
|
||
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
|
||
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
|
||
.my-work-card:hover { border-color:var(--accent); }
|
||
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
|
||
.mark-update-read { min-height:44px; width:100%; }
|
||
.read-update { min-height:44px; width:100%; }
|
||
.load-more-notifications { min-height:44px; width:100%; margin-top:10px; }
|
||
.load-more-notifications[hidden] { display:none; }
|
||
.load-more-work { min-height:44px; width:100%; margin-top:10px; }
|
||
.load-more-work[hidden] { display:none; }
|
||
.my-work-bulk { position:sticky; bottom:0; z-index:4; margin:10px -4px -12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||
.my-work-bulk button { min-height:44px; width:100%; }
|
||
.my-work[data-stale="true"] { border-color:#fcd34d; }
|
||
.review-sheet { position:fixed; inset:0; z-index:50; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||
.review-sheet.open { display:flex; }
|
||
.review-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||
.review-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
|
||
.review-sheet-body { white-space:pre-wrap; overflow-wrap:anywhere; }
|
||
.review-file, .review-history { padding:8px 0; border-bottom:1px solid #1b2d45; overflow-wrap:anywhere; }
|
||
.review-file-toggle { min-height:44px; width:100%; display:flex; align-items:flex-start; justify-content:space-between; gap:8px; text-align:left; }
|
||
.review-file-toggle strong { overflow-wrap:anywhere; }
|
||
.review-file.reviewed { opacity:.72; }
|
||
.review-file.reviewed .review-file-toggle { border-color:#22c55e; }
|
||
.review-mark { min-height:44px; width:100%; margin-top:8px; }
|
||
.review-mark[aria-pressed="true"] { border-color:#22c55e; background:#123c2a; }
|
||
.review-note { min-height:88px; margin-top:6px; }
|
||
.review-feedback { display:grid; gap:8px; }
|
||
.review-feedback label { display:grid; gap:6px; }
|
||
.review-feedback select { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||
.review-handoff { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||
.review-handoff button { min-height:44px; }
|
||
.review-handoff-link { min-height:44px; display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:8px; color:#bfdbfe; font-weight:700; text-decoration:none; }
|
||
.review-handoff-link[hidden] { display:none; }
|
||
.review-copy-fallback { min-height:140px; }
|
||
.review-progress-actions { position:sticky; bottom:0; z-index:2; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:8px -4px 0; padding:10px 4px; background:rgba(11,21,38,.96); border-top:1px solid #2a496e; }
|
||
.review-progress-actions button { min-height:44px; max-width:100%; }
|
||
.review-diff { overflow-x:auto; max-width:100%; margin-top:8px; white-space:pre; }
|
||
.review-diff-line { display:block; min-width:max-content; }
|
||
.review-inline-target { min-height:44px; width:100%; padding:8px; border:0; border-radius:0; text-align:left; font:inherit; white-space:pre; }
|
||
.review-inline-target.has-draft { box-shadow:inset 4px 0 #fbbf24; }
|
||
.review-inline-composer { position:sticky; bottom:0; z-index:4; display:grid; gap:8px; padding:10px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); border:1px solid #60a5fa; border-radius:10px; background:#0b1526; }
|
||
.review-inline-composer[hidden] { display:none; }
|
||
.review-inline-composer textarea { min-height:96px; width:100%; }
|
||
.review-inline-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||
.review-inline-actions button { min-height:44px; }
|
||
.review-diff-line.hunk { color:#93c5fd; }
|
||
.review-diff-line.added { color:#86efac; background:rgba(34,197,94,.09); }
|
||
.review-diff-line.removed { color:#fca5a5; background:rgba(239,68,68,.09); }
|
||
.review-diff-note, .review-diff-empty { display:block; padding:8px; color:#fcd34d; white-space:normal; }
|
||
.review-action { min-height:44px; }
|
||
.review-retry { min-height:44px; margin-top:10px; }
|
||
.update-sheet { position:fixed; inset:0; z-index:55; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||
.update-sheet.open { display:flex; }
|
||
.update-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||
.update-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||
.update-sheet-header button { min-height:44px; }
|
||
.update-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||
.update-reply { display:grid; gap:8px; margin-top:16px; }
|
||
.update-reply textarea { width:100%; min-height:112px; resize:vertical; }
|
||
.update-reply button { min-height:44px; width:100%; }
|
||
.update-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||
.update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||
.update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||
.update-retry { min-height:44px; width:100%; margin-top:10px; }
|
||
.issue-sheet { position:fixed; inset:0; z-index:56; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||
.issue-sheet.open { display:flex; }
|
||
.issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||
.issue-sheet-header button { min-height:44px; }
|
||
.issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||
.issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; }
|
||
.issue-comment-composer { display:grid; gap:8px; margin-top:16px; }
|
||
.issue-comment-composer button { min-height:44px; width:100%; }
|
||
.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||
.issue-retry { min-height:44px; width:100%; margin-top:10px; }
|
||
.new-issue { min-height:44px; }
|
||
.create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||
.create-issue-sheet.open { display:flex; }
|
||
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||
.create-issue-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||
.create-issue-header button, .create-issue-actions button { min-height:44px; }
|
||
.create-issue-form { display:grid; gap:12px; }
|
||
.create-issue-form label { display:grid; gap:6px; }
|
||
.create-issue-form select { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||
.create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; }
|
||
.create-issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; }
|
||
.create-issue-label-option { min-height:44px; display:flex !important; grid-template-columns:auto 1fr !important; align-items:center; gap:8px; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
|
||
.create-issue-label-option input { width:20px; height:20px; margin:0; }
|
||
.create-issue-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||
.pull-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||
.pull-sheet.open { display:flex; }
|
||
.pull-sheet-panel { width:min(560px,100%); height:100dvh; overflow:auto; padding:18px; padding-bottom:calc(90px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||
.pull-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||
.pull-sheet-header button, .pull-sheet-actions button, .pull-sheet-actions a, .pull-comment-composer button { min-height:44px; }
|
||
.pull-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||
.pull-file, .pull-comment-card { margin:8px 0; padding:10px; border:1px solid #203a5c; border-radius:10px; }
|
||
.pull-comment-composer textarea { width:100%; min-height:110px; resize:vertical; }
|
||
.pull-sheet-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||
.pull-sheet-actions a { display:grid; place-items:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||
.pull-retry { min-height:44px; width:100%; margin-top:10px; }
|
||
@media (max-width: 600px) {
|
||
header { align-items:flex-start; }
|
||
.my-work { margin:0; }
|
||
.my-work-list { grid-template-columns:1fr; }
|
||
.work-filters { width:100%; }
|
||
.work-filter { flex:1 1 calc(50% - 8px); }
|
||
.review-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||
.update-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||
.issue-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||
.create-issue-panel { width:100%; border-left:0; padding:14px; }
|
||
.pull-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||
}
|
||
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
|
||
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
|
||
@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<canvas id="bg"></canvas>
|
||
<header>
|
||
<div><span class="obi"></span> <strong style="margin-left:6px;">Stackchain</strong> <span class="muted">Dashboard</span></div>
|
||
<div class="toolbar">
|
||
<div class="status"><span class="dot"></span><span class="small" id="status">Live</span></div>
|
||
<button id="refresh">Refresh</button>
|
||
<button id="open-palette">Command</button>
|
||
<span class="small muted" id="clock"></span>
|
||
</div>
|
||
</header>
|
||
|
||
<main>
|
||
<section class="panel my-work" id="my-work">
|
||
<div class="my-work-header">
|
||
<div>
|
||
<h2>My Work</h2>
|
||
<div class="small" id="my-work-status" aria-live="polite">Loading assigned work…</div>
|
||
</div>
|
||
<button class="new-issue" id="new-issue" type="button">New issue</button>
|
||
<div class="work-filters" aria-label="Filter My Work">
|
||
<button class="work-filter" data-work-filter="all" aria-pressed="true">All <span data-work-count="all">0</span></button>
|
||
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
|
||
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
|
||
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews <span data-work-count="review">0</span></button>
|
||
<button class="work-filter" data-work-filter="update" aria-pressed="false">Updates <span data-work-count="update">0</span></button>
|
||
</div>
|
||
</div>
|
||
<div class="my-work-list" id="my-work-list"></div>
|
||
<div class="small" id="work-page-status" aria-live="polite"></div>
|
||
<button class="load-more-work" id="load-more-work" type="button" hidden>Load older work</button>
|
||
<div class="small" id="notification-page-status" aria-live="polite"></div>
|
||
<button class="load-more-notifications" id="load-more-notifications" type="button" hidden>Load older updates</button>
|
||
<div class="small" id="my-work-action-status" aria-live="assertive"></div>
|
||
<div class="my-work-bulk" id="bulk-mark-read-bar" hidden>
|
||
<button id="bulk-mark-read" type="button"></button>
|
||
</div>
|
||
</section>
|
||
<aside class="sidebar">
|
||
<details class="panel stack" data-panel-key="context" open>
|
||
<summary><h2>Context & view</h2></summary>
|
||
<div>
|
||
<h2>Context</h2>
|
||
<div class="muted" id="context">Loading…</div>
|
||
</div>
|
||
<div>
|
||
<h2>View</h2>
|
||
<div class="muted" id="view-hint">—</div>
|
||
</div>
|
||
</details>
|
||
<details class="panel stack" data-panel-key="ai-stream" open>
|
||
<summary><h2>AI Stream</h2></summary>
|
||
<div id="ai" class="muted">Loading…</div>
|
||
</details>
|
||
</aside>
|
||
|
||
<section class="stack" id="work">
|
||
<details class="panel" id="issues" data-panel-key="issues" open>
|
||
<summary><h2>Issues</h2></summary>
|
||
<div id="issues-content" class="muted">Loading…</div>
|
||
</details>
|
||
<details class="panel" id="prs" data-panel-key="pull-requests" open>
|
||
<summary><h2>Pull Requests</h2></summary>
|
||
<div id="prs-content" class="muted">Loading…</div>
|
||
</details>
|
||
<details class="panel" data-panel-key="gitea-events" open>
|
||
<summary><h2>Gitea event stream</h2></summary>
|
||
<div id="gitea-events" class="muted" aria-live="polite">Loading…</div>
|
||
<div id="gitea-events-status" class="small" aria-live="polite">Waiting for activity…</div>
|
||
</details>
|
||
<details class="panel" data-panel-key="widgets" open>
|
||
<summary><h2>Live Widgets</h2></summary>
|
||
<div id="widgets" class="stack">
|
||
<div class="widget">
|
||
<h3>Live clock</h3>
|
||
<div class="small" id="widget-clock">—</div>
|
||
</div>
|
||
<div class="widget">
|
||
<h3>Repo mix</h3>
|
||
<div class="small" id="repo-mix">Loading…</div>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</section>
|
||
|
||
<aside class="sidebar">
|
||
<details class="panel stack" data-panel-key="layout" open>
|
||
<summary><h2>Layout</h2></summary>
|
||
<div class="muted" id="layout-hint">—</div>
|
||
</details>
|
||
<details class="panel stack" data-panel-key="markdown" open>
|
||
<summary><h2>Markdown</h2></summary>
|
||
<textarea id="md-input" placeholder="# Write markdown here...
|
||
- bullet
|
||
- **bold**
|
||
- `code`"></textarea>
|
||
<div id="md-preview" style="margin-top:8px;"></div>
|
||
</details>
|
||
</aside>
|
||
</main>
|
||
|
||
<div id="cmd-palette" role="dialog" aria-label="Command palette">
|
||
<input id="cmd-input" type="text" placeholder="Type a command, e.g. whiteboard, markdown..." />
|
||
<div id="cmd-results" class="stack" style="margin-top:8px;"></div>
|
||
</div>
|
||
|
||
<div id="whiteboard-modal">
|
||
<div class="modal">
|
||
<div class="modal-actions">
|
||
<strong>Whiteboard</strong>
|
||
<div>
|
||
<span class="badge">Click & drag to draw</span>
|
||
<button id="close-whiteboard">Close</button>
|
||
</div>
|
||
</div>
|
||
<canvas id="wb"></canvas>
|
||
<div class="row" style="margin-top:8px;">
|
||
<button id="wb-clear">Clear</button>
|
||
<button id="wb-save">Save PNG</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="issue-sheet" id="issue-sheet" role="dialog" aria-modal="true" aria-labelledby="issue-sheet-title">
|
||
<section class="issue-sheet-panel">
|
||
<div class="issue-sheet-header">
|
||
<div>
|
||
<div class="small" id="issue-sheet-key"></div>
|
||
<h3 id="issue-sheet-title">Assigned issue</h3>
|
||
</div>
|
||
<button id="close-issue-sheet" type="button">Close sheet</button>
|
||
</div>
|
||
<div id="issue-sheet-status" class="small" aria-live="polite">Choose an issue.</div>
|
||
<button class="issue-retry" id="retry-issue-load" type="button" hidden>Retry loading issue</button>
|
||
<div class="row"><span id="issue-labels"></span><span class="small" id="issue-assignees"></span></div>
|
||
<p class="issue-sheet-content" id="issue-sheet-body"></p>
|
||
<h2>Recent discussion</h2>
|
||
<div id="issue-comments"></div>
|
||
<section class="issue-comment-composer" aria-labelledby="issue-comment-title">
|
||
<h2 id="issue-comment-title">Add comment</h2>
|
||
<textarea id="issue-comment" maxlength="10000" placeholder="Write a comment"></textarea>
|
||
<button id="send-issue-comment" type="button">Post comment</button>
|
||
<div id="issue-comment-status" class="small" aria-live="assertive"></div>
|
||
</section>
|
||
<div class="issue-sheet-actions">
|
||
<button id="close-issue" type="button">Close issue</button>
|
||
<a id="open-issue-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div class="create-issue-sheet" id="create-issue-sheet" role="dialog" aria-modal="true" aria-labelledby="create-issue-heading">
|
||
<section class="create-issue-panel">
|
||
<div class="create-issue-header">
|
||
<h3 id="create-issue-heading">New issue</h3>
|
||
<button id="cancel-new-issue" type="button">Cancel</button>
|
||
</div>
|
||
<form class="create-issue-form" id="create-issue-form">
|
||
<label for="create-issue-repository">Repository
|
||
<select id="create-issue-repository" required></select>
|
||
</label>
|
||
<label for="create-issue-title">Title
|
||
<input id="create-issue-title" type="text" maxlength="255" required autocomplete="off" />
|
||
</label>
|
||
<label for="create-issue-body">Description <span class="small">Optional</span>
|
||
<textarea id="create-issue-body" maxlength="10000"></textarea>
|
||
</label>
|
||
<fieldset class="create-issue-labels" id="create-issue-labels" aria-describedby="create-issue-label-status">
|
||
<legend>Labels <span class="small">Optional</span></legend>
|
||
<div class="small" id="create-issue-label-status" aria-live="polite">Choose a repository to load labels.</div>
|
||
<div class="create-issue-label-list" id="create-issue-label-list"></div>
|
||
</fieldset>
|
||
<div class="small">The issue will be assigned to you.</div>
|
||
<div class="create-issue-actions">
|
||
<button id="submit-new-issue" type="submit">Create & assign to me</button>
|
||
<div id="create-issue-status" class="small" aria-live="assertive"></div>
|
||
</div>
|
||
</form>
|
||
</section>
|
||
</div>
|
||
|
||
<div class="update-sheet" id="update-sheet" role="dialog" aria-modal="true" aria-labelledby="update-sheet-title">
|
||
<section class="update-sheet-panel">
|
||
<div class="update-sheet-header">
|
||
<div>
|
||
<div class="small" id="update-sheet-key"></div>
|
||
<h3 id="update-sheet-title">Unread update</h3>
|
||
</div>
|
||
<button id="keep-update-unread" type="button">Keep unread</button>
|
||
</div>
|
||
<div id="update-sheet-status" class="small" aria-live="polite">Choose an update.</div>
|
||
<button class="update-retry" id="retry-update-load" type="button" hidden>Retry loading update</button>
|
||
<div class="row">
|
||
<span class="pill" id="update-subject-type">Update</span>
|
||
<span class="pill" id="update-subject-state"></span>
|
||
</div>
|
||
<h2>Latest comment</h2>
|
||
<div class="small" id="update-comment-meta"></div>
|
||
<p class="update-sheet-content" id="update-comment-body"></p>
|
||
<details>
|
||
<summary><h2>Subject context</h2></summary>
|
||
<p class="update-sheet-content muted" id="update-subject-body"></p>
|
||
</details>
|
||
<section class="update-reply" aria-labelledby="update-reply-title">
|
||
<h2 id="update-reply-title">Reply</h2>
|
||
<label for="update-reply" class="small">Continue this issue or pull-request conversation</label>
|
||
<textarea id="update-reply" maxlength="10000" placeholder="Write a reply"></textarea>
|
||
<button id="send-update-reply" type="button">Send reply</button>
|
||
<div id="update-reply-status" class="small" aria-live="assertive"></div>
|
||
</section>
|
||
<div class="update-sheet-actions">
|
||
<button id="mark-update-read-next" type="button">Mark read & next</button>
|
||
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div class="pull-sheet" id="pull-sheet" role="dialog" aria-modal="true" aria-labelledby="pull-sheet-title">
|
||
<section class="pull-sheet-panel">
|
||
<div class="pull-sheet-header">
|
||
<div><div class="small" id="pull-sheet-key"></div><h3 id="pull-sheet-title">Assigned pull request</h3></div>
|
||
<button id="close-pull-sheet" type="button">Close</button>
|
||
</div>
|
||
<div id="pull-sheet-status" class="small" aria-live="polite">Choose a pull request.</div>
|
||
<button class="pull-retry" id="retry-pull-load" type="button" hidden>Retry loading pull request</button>
|
||
<div class="row"><span class="pill" id="pull-ci-state">CI unknown</span><span class="pill" id="pull-merge-state">Checking merge status</span></div>
|
||
<p class="pull-sheet-content" id="pull-sheet-body"></p>
|
||
<h2>Changed files</h2><div id="pull-files"></div>
|
||
<h2>Recent discussion</h2><div id="pull-comments"></div>
|
||
<section class="pull-comment-composer" aria-labelledby="pull-comment-title">
|
||
<h2 id="pull-comment-title">Add comment</h2>
|
||
<textarea id="pull-comment" maxlength="10000" placeholder="Write a comment"></textarea>
|
||
<button id="send-pull-comment" type="button">Post comment</button>
|
||
<div id="pull-comment-status" class="small" aria-live="assertive"></div>
|
||
</section>
|
||
<div class="pull-sheet-actions">
|
||
<button id="merge-pull" type="button" disabled>Merge</button>
|
||
<a id="open-pull-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div class="review-sheet" id="review-sheet" role="dialog" aria-modal="true" aria-labelledby="review-sheet-title">
|
||
<section class="review-sheet-panel">
|
||
<div class="review-sheet-header">
|
||
<div>
|
||
<div class="small" id="review-sheet-key"></div>
|
||
<h3 id="review-sheet-title">Pull request review</h3>
|
||
</div>
|
||
<button class="review-action" id="close-review-sheet">Close</button>
|
||
</div>
|
||
<div id="review-sheet-status" class="small" aria-live="polite">Choose a review request.</div>
|
||
<button class="review-retry" id="retry-review-load" hidden>Retry loading review</button>
|
||
<p class="review-sheet-body" id="review-sheet-body"></p>
|
||
<div class="row"><span class="pill" id="review-ci-state">CI unknown</span><a id="open-review-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a></div>
|
||
<h2>Changed files</h2>
|
||
<div id="review-files" class="muted"></div>
|
||
<section class="review-inline-composer" id="review-inline-composer" aria-labelledby="review-inline-title" hidden>
|
||
<strong id="review-inline-title">Comment on changed line</strong>
|
||
<label class="small" id="review-inline-location" for="review-inline-body"></label>
|
||
<textarea id="review-inline-body" maxlength="10000" placeholder="Write a line-specific comment"></textarea>
|
||
<div class="review-inline-actions">
|
||
<button id="save-inline-comment" type="button">Save draft comment</button>
|
||
<button id="delete-inline-comment" type="button">Delete draft</button>
|
||
<button id="cancel-inline-comment" type="button">Cancel</button>
|
||
</div>
|
||
</section>
|
||
<div class="review-progress-actions">
|
||
<span class="small" id="review-progress" aria-live="polite">0 of 0 files reviewed</span>
|
||
<button id="next-unreviewed-review" disabled>Next unreviewed</button>
|
||
</div>
|
||
<section class="review-feedback" aria-labelledby="review-feedback-title">
|
||
<h2 id="review-feedback-title">Draft feedback</h2>
|
||
<label>Intended decision
|
||
<select id="review-decision">
|
||
<option value="comment">Comment</option>
|
||
<option value="approve">Approve</option>
|
||
<option value="request_changes">Request changes</option>
|
||
</select>
|
||
</label>
|
||
<label>Overall summary
|
||
<textarea id="review-summary" placeholder="Summarize your review"></textarea>
|
||
</label>
|
||
<div class="review-handoff">
|
||
<button id="submit-review">Submit review</button>
|
||
<span class="small" id="review-submit-status" aria-live="assertive"></span>
|
||
<button id="copy-review-feedback">Copy feedback & continue in Gitea</button>
|
||
<span class="small" id="review-handoff-status" aria-live="polite"></span>
|
||
<textarea class="review-copy-fallback" id="review-copy-fallback" readonly hidden aria-label="Feedback to copy manually"></textarea>
|
||
<a class="review-handoff-link" id="review-handoff-link" target="_blank" rel="noopener noreferrer" hidden>Continue to Gitea</a>
|
||
</div>
|
||
</section>
|
||
<h2>Review history</h2>
|
||
<div id="review-history" class="muted"></div>
|
||
</section>
|
||
</div>
|
||
|
||
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
||
|
||
<script src="static/markdown.js"></script>
|
||
<script src="static/commands.js"></script>
|
||
<script src="static/widgets.js"></script>
|
||
<script src="static/my-work.js"></script>
|
||
<script src="static/issue-sheet.js"></script>
|
||
<script src="static/create-issue-sheet.js"></script>
|
||
<script src="static/pull-sheet.js"></script>
|
||
<script src="static/review-sheet.js"></script>
|
||
<script src="static/context-poller.js"></script>
|
||
<script>
|
||
(function(){
|
||
const qs = (s, el=document) => el.querySelector(s);
|
||
const fmt = (d) => new Date(d).toLocaleString();
|
||
const PANEL_STATE_KEY = "stackchain.panel-state.v1";
|
||
const panels = Array.from(document.querySelectorAll('details[data-panel-key]'));
|
||
let savedState = {};
|
||
try {
|
||
savedState = JSON.parse(localStorage.getItem(PANEL_STATE_KEY) || '{}');
|
||
} catch (e) {
|
||
savedState = {};
|
||
}
|
||
panels.forEach((panel) => {
|
||
if (Object.prototype.hasOwnProperty.call(savedState, panel.dataset.panelKey)) {
|
||
panel.open = savedState[panel.dataset.panelKey];
|
||
}
|
||
panel.addEventListener('toggle', () => {
|
||
const state = Object.fromEntries(panels.map((item) => [item.dataset.panelKey, item.open]));
|
||
try {
|
||
localStorage.setItem(PANEL_STATE_KEY, JSON.stringify(state));
|
||
} catch (e) {
|
||
console.warn('Could not persist panel state', e);
|
||
}
|
||
});
|
||
});
|
||
let liveMode = true;
|
||
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
|
||
let selectedWorkFilter = 'all';
|
||
try {
|
||
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
|
||
if (['all', 'issue', 'pull', 'review', 'update'].includes(savedFilter)) selectedWorkFilter = savedFilter;
|
||
} catch (e) {
|
||
console.warn('Could not restore My Work filter', e);
|
||
}
|
||
let lastMyWork = [];
|
||
let lastNotifications = [];
|
||
let lastContextSnapshot = null;
|
||
let notificationPagination = { page: 1, total: 0, has_more: false };
|
||
let workPagination = {};
|
||
let hasContextSnapshot = false;
|
||
let selectedReview = null;
|
||
let reviewTrigger = null;
|
||
let selectedUpdate = null;
|
||
let updateTrigger = null;
|
||
let selectedIssue = null;
|
||
let issueTrigger = null;
|
||
let selectedPull = null;
|
||
let pullTrigger = null;
|
||
let selectedPullDetail = null;
|
||
let creatingIssue = false;
|
||
let progress = null;
|
||
let draft = null;
|
||
let reviewFiles = [];
|
||
let selectedReviewHead = '';
|
||
let activeInlineTarget = null;
|
||
let bulkConfirmationPending = false;
|
||
let bulkMarkPending = false;
|
||
let reviewHandoffPending = false;
|
||
|
||
async function fetchReviewJson(url, options) {
|
||
const response = await fetch(url, options);
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(payload.error || 'Review request failed.');
|
||
return payload;
|
||
}
|
||
const reviewController = createReviewController({ fetchJson: fetchReviewJson });
|
||
const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
||
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
||
const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
||
|
||
function setStatus(msg) { qs('#status').textContent = msg || 'Live'; }
|
||
function setClock() { qs('#clock').textContent = fmt(new Date()); }
|
||
setClock(); setInterval(setClock, 1000);
|
||
|
||
async function fetchLiveSnapshot() {
|
||
const res = await fetch('api/v1/live', { headers: { Accept: 'application/json' } });
|
||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||
return res.json();
|
||
}
|
||
|
||
async function markNotificationRead(notificationId) {
|
||
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/read', {
|
||
method: 'PATCH',
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(payload.error || 'Mark read failed.');
|
||
return payload;
|
||
}
|
||
|
||
async function markNotificationsRead(ids) {
|
||
const response = await fetch('api/v1/notifications/read', {
|
||
method: 'PATCH',
|
||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ ids }),
|
||
});
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(payload.error || 'Bulk mark read failed.');
|
||
return payload;
|
||
}
|
||
|
||
async function fetchNotificationPage(page) {
|
||
const response = await fetch('api/v1/notifications?page=' + encodeURIComponent(page), {
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(payload.error || 'Loading unread updates failed.');
|
||
return payload;
|
||
}
|
||
|
||
async function fetchNotificationDetail(notificationId) {
|
||
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId), {
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(payload.error || 'Loading the update failed.');
|
||
return payload;
|
||
}
|
||
|
||
async function fetchWorkPage(stream, page) {
|
||
const response = await fetch('api/v1/work/' + encodeURIComponent(stream) + '?page=' + encodeURIComponent(page), {
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(payload.error || 'Loading older work failed.');
|
||
return payload;
|
||
}
|
||
|
||
async function postNotificationReply(notificationId, body) {
|
||
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/reply', {
|
||
method: 'POST',
|
||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ body }),
|
||
});
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(payload.error || 'Posting the reply failed.');
|
||
return payload;
|
||
}
|
||
|
||
const notificationAcknowledger = createNotificationAcknowledger({
|
||
markRead: markNotificationRead,
|
||
onItems: items => {
|
||
lastMyWork = items;
|
||
refreshMyWorkView();
|
||
},
|
||
onStatus: message => { qs('#my-work-action-status').textContent = message; },
|
||
});
|
||
const bulkNotificationAcknowledger = createBulkNotificationAcknowledger({
|
||
markRead: markNotificationsRead,
|
||
onItems: items => {
|
||
lastMyWork = items;
|
||
refreshMyWorkView();
|
||
},
|
||
onStatus: message => { qs('#my-work-action-status').textContent = message; },
|
||
});
|
||
const notificationPager = createNotificationPager({
|
||
load: fetchNotificationPage,
|
||
onNotifications: items => {
|
||
lastNotifications = items;
|
||
if (lastContextSnapshot) {
|
||
lastContextSnapshot.notifications = lastNotifications;
|
||
paintMyWork(lastContextSnapshot);
|
||
}
|
||
},
|
||
onPagination: pagination => {
|
||
notificationPagination = pagination;
|
||
const loaded = Math.min(pagination.total, pagination.page * 50);
|
||
qs('#notification-page-status').textContent = pagination.total ?
|
||
loaded + ' of ' + pagination.total + ' unread updates loaded.' : '';
|
||
qs('#load-more-notifications').hidden =
|
||
selectedWorkFilter !== 'update' || !pagination.has_more;
|
||
},
|
||
onStatus: message => { qs('#my-work-action-status').textContent = message; },
|
||
});
|
||
const workPager = createWorkPager({
|
||
load: fetchWorkPage,
|
||
onItems: (stream, items) => {
|
||
if (!lastContextSnapshot) return;
|
||
if (stream === 'issue') lastContextSnapshot.issues = items;
|
||
else lastContextSnapshot.pull_requests = items;
|
||
paintMyWork(lastContextSnapshot);
|
||
},
|
||
onPagination: pagination => {
|
||
workPagination = pagination;
|
||
updateWorkPaginationControls();
|
||
},
|
||
onStatus: message => { qs('#my-work-action-status').textContent = message; },
|
||
});
|
||
const notificationReplier = createNotificationReplier({
|
||
post: postNotificationReply,
|
||
storage: localStorage,
|
||
onStatus: message => { qs('#update-reply-status').textContent = message; },
|
||
});
|
||
const notificationReader = createNotificationReader({
|
||
load: fetchNotificationDetail,
|
||
markRead: markNotificationRead,
|
||
onOpen: item => {
|
||
selectedUpdate = item;
|
||
qs('#update-sheet').classList.add('open');
|
||
qs('#update-sheet-key').textContent = item.key || '';
|
||
qs('#update-sheet-title').textContent = item.title || 'Unread update';
|
||
qs('#update-comment-meta').textContent = '';
|
||
qs('#update-comment-body').textContent = '';
|
||
qs('#update-subject-body').textContent = '';
|
||
qs('#update-subject-type').textContent = item.subject_type || 'Update';
|
||
qs('#update-subject-state').textContent = item.state || '';
|
||
qs('#open-update-gitea').href = item.url || '#';
|
||
qs('#update-reply').value = notificationReplier.loadDraft(item);
|
||
qs('#update-reply-status').textContent = '';
|
||
qs('#send-update-reply').disabled = false;
|
||
qs('#retry-update-load').hidden = true;
|
||
qs('#keep-update-unread').focus();
|
||
},
|
||
onDetail: detail => {
|
||
const comment = detail.latest_comment || {};
|
||
qs('#update-sheet-title').textContent = detail.title || 'Unread update';
|
||
qs('#update-subject-type').textContent = detail.subject_type || 'Update';
|
||
qs('#update-subject-state').textContent = detail.state || '';
|
||
qs('#update-comment-meta').textContent = comment.author ?
|
||
comment.author + (comment.created_at ? ' · ' + fmt(comment.created_at) : '') :
|
||
(comment.created_at ? fmt(comment.created_at) : 'No comment author reported');
|
||
qs('#update-comment-body').textContent = comment.body || 'No comment body was provided.';
|
||
qs('#update-subject-body').textContent = detail.subject_body || 'No subject context was provided.';
|
||
qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#';
|
||
qs('#retry-update-load').hidden = true;
|
||
},
|
||
onItems: items => {
|
||
const readId = selectedUpdate?.notification_id;
|
||
lastMyWork = items;
|
||
if (Number.isInteger(readId)) {
|
||
lastNotifications = lastNotifications.filter(item => item.id !== readId);
|
||
}
|
||
refreshMyWorkView();
|
||
},
|
||
onStatus: message => {
|
||
qs('#update-sheet-status').textContent = message;
|
||
qs('#retry-update-load').hidden = !message.startsWith('Could not load update.');
|
||
if (message === 'Inbox cleared.') qs('#my-work-action-status').textContent = message;
|
||
},
|
||
onClose: () => closeUpdateSheet(false),
|
||
});
|
||
|
||
function renderContextSnapshot(data) {
|
||
liveMode = true;
|
||
hasContextSnapshot = true;
|
||
if (lastContextSnapshot && Object.values(workPagination).some(page => page.page > 1)) {
|
||
const merge = (older, latest) => {
|
||
const byId = new Map((older || []).map(item => [item.id, item]));
|
||
(latest || []).forEach(item => {
|
||
const current = byId.get(item.id) || {};
|
||
const reasons = Array.from(new Set(
|
||
(current.work_reasons || []).concat(item.work_reasons || [])
|
||
));
|
||
byId.set(item.id, {
|
||
...current, ...item, ...(reasons.length ? { work_reasons: reasons } : {}),
|
||
});
|
||
});
|
||
return Array.from(byId.values());
|
||
};
|
||
data.issues = merge(lastContextSnapshot.issues, data.issues);
|
||
data.pull_requests = merge(lastContextSnapshot.pull_requests, data.pull_requests);
|
||
}
|
||
lastContextSnapshot = data;
|
||
if (data.work_pagination) workPager.reset(data.work_pagination);
|
||
if (data.error && lastMyWork.length) markMyWorkStale();
|
||
else paintMyWork(data);
|
||
qs('#context').innerHTML = '<div class="kv"><div class="label">User</div><div class="value">' + escapeHtml(data.user?.full_name || data.user?.login || '—') + '</div>' +
|
||
'<div class="label">Repos</div><div class="value">' + (data.repos?.length || 0) + '</div>' +
|
||
'<div class="label">Issues</div><div class="value">' + (data.issues?.length || 0) + '</div>' +
|
||
'<div class="label">PRs</div><div class="value">' + (data.pull_requests?.length || 0) + '</div></div>';
|
||
qs('#view-hint').textContent = 'Active view: ' + (data.view || 'dashboard');
|
||
|
||
const issuesBox = qs('#issues-content');
|
||
const openIssues = (data.issues || []).filter(i => i.state === 'open').slice(0, 12);
|
||
issuesBox.innerHTML = (openIssues.length ? openIssues.map(i => '<div style="margin:6px 0;"><a href="' + escAttr(i.url) + '" target="_blank">#' + i.number + ' ' + escapeHtml(i.title) + '</a>' +
|
||
'<div class="muted">' + (i.labels || []).map(l => '<span class="pill">' + escapeHtml(String(l)) + '</span>').join(' ') + '</div></div>').join('') : '<div class="muted">No open issues.</div>');
|
||
|
||
const prsBox = qs('#prs-content');
|
||
const openPrs = (data.pull_requests || []).slice(0, 12);
|
||
prsBox.innerHTML = (openPrs.length ? openPrs.map(p => '<div style="margin:6px 0;"><a href="' + escAttr(p.url) + '" target="_blank">#' + p.number + ' ' + escapeHtml(p.title) + '</a>' +
|
||
'<div class="muted">' + escapeHtml(p.state) + ' by ' + escapeHtml(String(p.user || '')) + '</div></div>').join('') : '<div class="muted">No PRs.</div>');
|
||
|
||
paintDeltas(data.deltas || []);
|
||
qs('#layout-hint').innerHTML = '<div class="kv"><div class="label">Active view</div><div class="value">' + escapeHtml(data.view || 'dashboard') + '</div><div class="label">Deltas</div><div class="value">' + (data.deltas||[]).length + '</div></div>';
|
||
renderRepoMix(qs('#repo-mix'), data);
|
||
setStatus(data.error ? 'Degraded · ' + data.error : 'Live · updated just now');
|
||
setClock();
|
||
}
|
||
|
||
function handleContextError(e) {
|
||
console.error('context failed', e);
|
||
liveMode = false;
|
||
setStatus(hasContextSnapshot ? 'Update failed · showing last snapshot' : 'Unavailable');
|
||
if (!hasContextSnapshot) {
|
||
qs('#context').innerHTML = '<div class="muted">Context unavailable.</div>';
|
||
qs('#view-hint').textContent = 'Active view unavailable.';
|
||
qs('#issues-content').innerHTML = '<div class="muted">Work items unavailable.</div>';
|
||
qs('#prs-content').innerHTML = '<div class="muted">Work items unavailable.</div>';
|
||
paintDeltas([]);
|
||
}
|
||
markMyWorkStale();
|
||
}
|
||
|
||
function paintMyWork(data) {
|
||
lastMyWork = buildMyWork(data);
|
||
refreshMyWorkView();
|
||
}
|
||
|
||
function refreshMyWorkView() {
|
||
const counts = countMyWork(lastMyWork);
|
||
Object.entries(counts).forEach(([filter, count]) => {
|
||
const element = qs('[data-work-count="' + filter + '"]');
|
||
if (element) element.textContent = count;
|
||
});
|
||
qs('#my-work').removeAttribute('data-stale');
|
||
qs('#my-work-status').textContent = lastMyWork.length ?
|
||
summarizeMyWork(lastMyWork) : 'No assigned work, review requests, or unread updates.';
|
||
updateWorkPaginationControls();
|
||
renderMyWork();
|
||
}
|
||
|
||
function activeWorkStreams() {
|
||
if (selectedWorkFilter === 'issue') return ['issue'];
|
||
if (selectedWorkFilter === 'pull') return ['pull'];
|
||
if (selectedWorkFilter === 'review') return ['review'];
|
||
if (selectedWorkFilter === 'all') return ['issue', 'pull', 'review'];
|
||
return [];
|
||
}
|
||
|
||
function updateWorkPaginationControls() {
|
||
const labels = { issue: 'issues', pull: 'pull requests', review: 'review requests' };
|
||
const streams = activeWorkStreams();
|
||
const incomplete = streams.filter(stream => workPagination[stream]?.has_more);
|
||
const summaries = streams.flatMap(stream => {
|
||
const page = workPagination[stream];
|
||
return page && page.total ?
|
||
[Math.min(page.total, page.page * 50) + ' of ' + page.total + ' ' + labels[stream]] : [];
|
||
});
|
||
qs('#work-page-status').textContent = summaries.join(' · ');
|
||
qs('#load-more-work').hidden = incomplete.length === 0;
|
||
qs('#load-more-work').textContent = incomplete.length ?
|
||
'Load older ' + labels[incomplete[0]] : 'Load older work';
|
||
}
|
||
|
||
function renderMyWork() {
|
||
const visible = filterMyWork(lastMyWork, selectedWorkFilter);
|
||
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
|
||
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
|
||
const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind);
|
||
const contents =
|
||
'<span class="small">' + escapeHtml(item.key) + ' · ' + escapeHtml(item.kind === 'pull' ? 'PR' : (item.kind === 'update' ? 'Update' : 'Issue')) + '</span>' +
|
||
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
|
||
'<span class="pill">' + escapeHtml(item.reason) + '</span>' +
|
||
(item.has_update ? ' <span class="pill">Unread update</span>' : '') +
|
||
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '');
|
||
const markRead = item.has_update && Number.isInteger(item.notification_id) ?
|
||
'<button class="mark-update-read" data-notification-id="' + item.notification_id + '">Mark read</button>' : '';
|
||
const readUpdate = item.has_update && Number.isInteger(item.notification_id) ?
|
||
'<button class="read-update" data-update-index="' + index + '">Read update</button>' : '';
|
||
if (item.is_review) {
|
||
return '<article class="my-work-card"><button class="my-work-card-main review-trigger" data-review-index="' + index + '">' + contents + '</button>' + readUpdate + markRead + '</article>';
|
||
}
|
||
if (item.kind === 'issue') {
|
||
return '<article class="my-work-card"><button class="my-work-card-main issue-trigger" data-issue-index="' + index + '">' + contents + '</button>' + readUpdate + markRead + '</article>';
|
||
}
|
||
if (item.kind === 'pull') {
|
||
return '<article class="my-work-card"><button class="my-work-card-main pull-trigger" data-pull-index="' + index + '">' + contents + '</button>' + readUpdate + markRead + '</article>';
|
||
}
|
||
return '<article class="my-work-card"><a class="my-work-card-main" href="' + escAttr(item.url) + '" target="_blank" rel="noopener noreferrer">' + contents + '</a>' + readUpdate + markRead + '</article>';
|
||
}).join('') : '<div class="muted">' + (incomplete ?
|
||
'More work is available. Load the next page.' :
|
||
'No ' + (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))) + '.') + '</div>';
|
||
document.querySelectorAll('[data-review-index]').forEach(button => {
|
||
button.addEventListener('click', () => openReviewSheet(lastMyWork[Number(button.dataset.reviewIndex)], button));
|
||
});
|
||
document.querySelectorAll('[data-issue-index]').forEach(button => {
|
||
button.addEventListener('click', () => openIssueSheet(lastMyWork[Number(button.dataset.issueIndex)], button));
|
||
});
|
||
document.querySelectorAll('[data-pull-index]').forEach(button => {
|
||
button.addEventListener('click', () => openPullSheet(lastMyWork[Number(button.dataset.pullIndex)], button));
|
||
});
|
||
document.querySelectorAll('[data-update-index]').forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
const item = lastMyWork[Number(button.dataset.updateIndex)];
|
||
if (!item) return;
|
||
updateTrigger = button;
|
||
notificationReader.open(item, lastMyWork);
|
||
});
|
||
});
|
||
document.querySelectorAll('[data-notification-id]').forEach(button => {
|
||
button.addEventListener('click', async () => {
|
||
const notificationId = Number(button.dataset.notificationId);
|
||
button.disabled = true;
|
||
const acknowledged = await notificationAcknowledger.acknowledge(lastMyWork, notificationId);
|
||
if (acknowledged) {
|
||
lastNotifications = lastNotifications.filter(item => item.id !== notificationId);
|
||
(document.querySelector('[data-notification-id]') || qs('[data-work-filter="update"]'))?.focus();
|
||
} else {
|
||
document.querySelector('[data-notification-id="' + notificationId + '"]')?.focus();
|
||
}
|
||
});
|
||
});
|
||
const allIds = notificationIds(visible);
|
||
const ids = allIds.slice(0, Math.min(lastNotifications.length, 50));
|
||
const bulkBar = qs('#bulk-mark-read-bar');
|
||
const bulkButton = qs('#bulk-mark-read');
|
||
bulkBar.hidden = selectedWorkFilter !== 'update' || ids.length === 0;
|
||
qs('#load-more-notifications').hidden =
|
||
selectedWorkFilter !== 'update' || !notificationPagination.has_more;
|
||
bulkButton.disabled = bulkMarkPending;
|
||
const bulkLabel = allIds.length > ids.length ?
|
||
'next ' + ids.length + ' of ' + allIds.length + ' loaded updates' :
|
||
'all ' + ids.length + ' updates';
|
||
bulkButton.textContent = bulkConfirmationPending ?
|
||
'Confirm marking ' + bulkLabel + ' read' :
|
||
'Mark ' + bulkLabel + ' read';
|
||
}
|
||
|
||
function reviewFileElement(filename) {
|
||
return Array.from(document.querySelectorAll('.review-file')).find(
|
||
element => element.dataset.reviewFilename === filename
|
||
);
|
||
}
|
||
|
||
function showReviewProgress(snapshot) {
|
||
const complete = snapshot.total > 0 && snapshot.reviewedCount === snapshot.total;
|
||
qs('#review-progress').textContent = complete ?
|
||
'All files reviewed — finish in Gitea' :
|
||
(snapshot.newHead ? 'New commits detected · ' : '') + snapshot.reviewedCount + ' of ' + snapshot.total + ' files reviewed';
|
||
qs('#next-unreviewed-review').disabled = !snapshot.nextFilename;
|
||
document.querySelectorAll('.review-mark').forEach(button => {
|
||
const reviewed = snapshot.reviewed.includes(button.dataset.reviewFilename);
|
||
button.setAttribute('aria-pressed', String(reviewed));
|
||
button.textContent = reviewed ? 'Reviewed' : 'Mark reviewed';
|
||
button.closest('.review-file')?.classList.toggle('reviewed', reviewed);
|
||
});
|
||
}
|
||
|
||
function openNextUnreviewed(snapshot) {
|
||
if (!snapshot?.nextFilename) return;
|
||
const file = reviewFileElement(snapshot.nextFilename);
|
||
if (!file) return;
|
||
const toggle = file.querySelector('.review-file-toggle');
|
||
const panel = toggle && document.getElementById(toggle.getAttribute('aria-controls'));
|
||
if (toggle && panel && toggle.getAttribute('aria-expanded') !== 'true') {
|
||
createReviewController.toggleDiff(toggle, panel);
|
||
}
|
||
file.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
toggle?.focus();
|
||
}
|
||
|
||
function renderIssueComment(comment) {
|
||
return '<div class="issue-comment"><div class="small">' +
|
||
escapeHtml(comment.author || 'Unknown author') +
|
||
(comment.created_at ? ' · ' + escapeHtml(fmt(comment.created_at)) : '') +
|
||
'</div><div class="issue-sheet-content">' +
|
||
escapeHtml(comment.body || 'No comment body provided.') + '</div></div>';
|
||
}
|
||
|
||
async function openIssueSheet(item, trigger) {
|
||
if (!item) return;
|
||
selectedIssue = item;
|
||
issueTrigger = trigger;
|
||
qs('#issue-sheet').classList.add('open');
|
||
qs('#issue-sheet-key').textContent = item.key || '';
|
||
qs('#issue-sheet-title').textContent = item.title || 'Assigned issue';
|
||
qs('#issue-sheet-status').textContent = 'Loading issue…';
|
||
qs('#issue-sheet-body').textContent = '';
|
||
qs('#issue-labels').textContent = '';
|
||
qs('#issue-assignees').textContent = '';
|
||
qs('#issue-comments').textContent = '';
|
||
qs('#issue-comment').value = issueController.loadDraft(item);
|
||
qs('#issue-comment-status').textContent = '';
|
||
qs('#retry-issue-load').hidden = true;
|
||
qs('#open-issue-gitea').href = item.url || '#';
|
||
qs('#send-issue-comment').disabled = false;
|
||
qs('#close-issue').disabled = false;
|
||
qs('#close-issue-sheet').focus();
|
||
try {
|
||
const detail = await issueController.load(item);
|
||
if (selectedIssue !== item) return;
|
||
qs('#issue-sheet-title').textContent = detail.title || 'Assigned issue';
|
||
qs('#issue-sheet-body').textContent = detail.body || 'No description provided.';
|
||
qs('#issue-labels').innerHTML = (detail.labels || []).map(label =>
|
||
'<span class="pill">' + escapeHtml(label) + '</span>'
|
||
).join(' ');
|
||
qs('#issue-assignees').textContent = (detail.assignees || []).length ?
|
||
'Assigned to ' + detail.assignees.join(', ') : 'No assignee reported';
|
||
qs('#issue-comments').innerHTML = (detail.comments || []).length ?
|
||
detail.comments.map(renderIssueComment).join('') : '<div class="muted">No comments yet.</div>';
|
||
qs('#open-issue-gitea').href = detail.url || item.url || '#';
|
||
qs('#issue-sheet-status').textContent = 'Issue ready · ' + (detail.state || 'open');
|
||
} catch (error) {
|
||
if (selectedIssue !== item) return;
|
||
qs('#issue-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.';
|
||
qs('#retry-issue-load').hidden = false;
|
||
qs('#retry-issue-load').focus();
|
||
}
|
||
}
|
||
|
||
function closeIssueSheet() {
|
||
qs('#issue-sheet').classList.remove('open');
|
||
selectedIssue = null;
|
||
if (issueTrigger?.isConnected) issueTrigger.focus();
|
||
}
|
||
|
||
async function openPullSheet(item, trigger) {
|
||
if (!item) return;
|
||
selectedPull = item;
|
||
pullTrigger = trigger;
|
||
selectedPullDetail = null;
|
||
qs('#pull-sheet').classList.add('open');
|
||
qs('#pull-sheet-key').textContent = item.key || '';
|
||
qs('#pull-sheet-title').textContent = item.title || 'Assigned pull request';
|
||
qs('#pull-sheet-status').textContent = 'Loading pull request…';
|
||
qs('#pull-sheet-body').textContent = '';
|
||
qs('#pull-files').textContent = '';
|
||
qs('#pull-comments').textContent = '';
|
||
qs('#pull-comment').value = pullController.loadDraft(item);
|
||
qs('#pull-comment-status').textContent = '';
|
||
qs('#pull-ci-state').textContent = 'CI unknown';
|
||
qs('#pull-merge-state').textContent = 'Checking merge status';
|
||
qs('#merge-pull').disabled = true;
|
||
qs('#retry-pull-load').hidden = true;
|
||
qs('#open-pull-gitea').href = item.url || '#';
|
||
qs('#close-pull-sheet').focus();
|
||
try {
|
||
const detail = await pullController.load(item);
|
||
if (selectedPull !== item) return;
|
||
selectedPullDetail = detail;
|
||
const eligibility = createPullSheet.mergeEligibility(detail);
|
||
qs('#pull-sheet-title').textContent = detail.title || 'Assigned pull request';
|
||
qs('#pull-sheet-body').textContent = detail.body || 'No description provided.';
|
||
qs('#pull-ci-state').textContent = 'CI ' + (detail.ci_state || 'unknown');
|
||
qs('#pull-merge-state').textContent = eligibility.reason;
|
||
qs('#merge-pull').disabled = !eligibility.allowed;
|
||
qs('#pull-files').innerHTML = (detail.files || []).length ? detail.files.map(file =>
|
||
'<div class="pull-file"><strong>' + escapeHtml(file.filename) + '</strong><div class="small">' +
|
||
escapeHtml(file.status || 'changed') + ' · +' + Number(file.additions || 0) + ' / −' + Number(file.deletions || 0) + '</div></div>'
|
||
).join('') : '<div class="muted">No changed files reported.</div>';
|
||
qs('#pull-comments').innerHTML = (detail.comments || []).length ? detail.comments.map(comment =>
|
||
'<div class="pull-comment-card">' + renderIssueComment(comment) + '</div>'
|
||
).join('') : '<div class="muted">No comments yet.</div>';
|
||
qs('#open-pull-gitea').href = detail.url || item.url || '#';
|
||
qs('#pull-sheet-status').textContent = 'Pull request ready · by ' + (detail.author || 'unknown author');
|
||
} catch (error) {
|
||
if (selectedPull !== item) return;
|
||
qs('#pull-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.';
|
||
qs('#retry-pull-load').hidden = false;
|
||
qs('#retry-pull-load').focus();
|
||
}
|
||
}
|
||
|
||
function closePullSheet() {
|
||
qs('#pull-sheet').classList.remove('open');
|
||
selectedPull = null;
|
||
selectedPullDetail = null;
|
||
if (pullTrigger?.isConnected) pullTrigger.focus();
|
||
}
|
||
|
||
function selectedIssueLabelIds() {
|
||
return Array.from(document.querySelectorAll('input[name="create-issue-label"]:checked'))
|
||
.map(input => Number(input.value)).filter(Number.isInteger);
|
||
}
|
||
|
||
function saveIssueCaptureDraft() {
|
||
issueCapture.saveDraft({
|
||
repository: qs('#create-issue-repository').value,
|
||
title: qs('#create-issue-title').value,
|
||
body: qs('#create-issue-body').value,
|
||
labelIds: selectedIssueLabelIds(),
|
||
});
|
||
}
|
||
|
||
async function loadIssueLabels(repository, selectedIds = []) {
|
||
const list = qs('#create-issue-label-list');
|
||
const status = qs('#create-issue-label-status');
|
||
list.innerHTML = '';
|
||
if (!repository) {
|
||
status.textContent = 'Choose a repository to load labels.';
|
||
return;
|
||
}
|
||
status.textContent = 'Loading labels…';
|
||
try {
|
||
const labels = await issueCapture.loadLabels(repository);
|
||
const selected = new Set(selectedIds.map(Number));
|
||
list.innerHTML = labels.map(label =>
|
||
'<label class="create-issue-label-option"><input type="checkbox" name="create-issue-label" value="' +
|
||
Number(label.id) + '"' + (selected.has(Number(label.id)) ? ' checked' : '') + '><span>' +
|
||
escapeHtml(label.name) + '</span></label>'
|
||
).join('');
|
||
status.textContent = labels.length ? 'Select labels to triage this issue.' : 'This repository has no labels.';
|
||
} catch (error) {
|
||
status.textContent = 'Labels could not be loaded. You can still create the issue without labels.';
|
||
}
|
||
}
|
||
|
||
function openCreateIssueSheet() {
|
||
const captureDraft = issueCapture.loadDraft();
|
||
const repositories = lastContextSnapshot?.repos || [];
|
||
qs('#create-issue-repository').innerHTML = repositories.map(repository =>
|
||
'<option value="' + escAttr(repository.full_name) + '">' + escapeHtml(repository.full_name) + '</option>'
|
||
).join('');
|
||
if (repositories.some(repository => repository.full_name === captureDraft.repository)) {
|
||
qs('#create-issue-repository').value = captureDraft.repository;
|
||
}
|
||
qs('#create-issue-title').value = captureDraft.title;
|
||
qs('#create-issue-body').value = captureDraft.body;
|
||
loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds);
|
||
qs('#create-issue-status').textContent = repositories.length ? '' : 'No accessible repositories are available.';
|
||
qs('#submit-new-issue').disabled = !repositories.length;
|
||
qs('#create-issue-sheet').classList.add('open');
|
||
creatingIssue = true;
|
||
qs('#create-issue-title').focus();
|
||
}
|
||
|
||
function closeCreateIssueSheet() {
|
||
qs('#create-issue-sheet').classList.remove('open');
|
||
creatingIssue = false;
|
||
qs('#new-issue').focus();
|
||
}
|
||
|
||
function inlineAnchor(target) {
|
||
return {
|
||
path: target.dataset.reviewFilename,
|
||
...(target.dataset.newPosition ? { new_position: Number(target.dataset.newPosition) } : {}),
|
||
...(target.dataset.oldPosition ? { old_position: Number(target.dataset.oldPosition) } : {}),
|
||
};
|
||
}
|
||
|
||
function sameInlineAnchor(comment, anchor) {
|
||
return comment.path === anchor.path && comment.new_position === anchor.new_position &&
|
||
comment.old_position === anchor.old_position;
|
||
}
|
||
|
||
function closeInlineComposer() {
|
||
qs('#review-inline-composer').hidden = true;
|
||
qs('#review-inline-body').value = '';
|
||
activeInlineTarget = null;
|
||
}
|
||
|
||
function openInlineComposer(target) {
|
||
if (!draft) return;
|
||
activeInlineTarget = target;
|
||
const anchor = inlineAnchor(target);
|
||
const existing = draft.snapshot().comments.find(comment => sameInlineAnchor(comment, anchor));
|
||
qs('#review-inline-location').textContent = anchor.path + ' · line ' +
|
||
(anchor.new_position || anchor.old_position) + (anchor.new_position ? ' (new)' : ' (old)');
|
||
qs('#review-inline-body').value = existing?.body || '';
|
||
qs('#delete-inline-comment').disabled = !existing;
|
||
qs('#review-inline-composer').hidden = false;
|
||
qs('#review-inline-body').focus();
|
||
}
|
||
|
||
async function openReviewSheet(item, trigger) {
|
||
selectedReview = item;
|
||
reviewTrigger = trigger;
|
||
qs('#review-sheet').classList.add('open');
|
||
qs('#review-sheet-key').textContent = item.key;
|
||
qs('#review-sheet-title').textContent = item.title;
|
||
qs('#review-sheet-status').textContent = 'Loading review details…';
|
||
qs('#retry-review-load').hidden = true;
|
||
qs('#review-sheet-body').textContent = '';
|
||
qs('#review-files').textContent = '';
|
||
qs('#review-progress').textContent = '0 of 0 files reviewed';
|
||
qs('#next-unreviewed-review').disabled = true;
|
||
qs('#review-history').textContent = '';
|
||
qs('#open-review-gitea').href = item.url;
|
||
qs('#review-decision').value = 'comment';
|
||
qs('#review-summary').value = '';
|
||
qs('#review-copy-fallback').hidden = true;
|
||
qs('#review-copy-fallback').value = '';
|
||
qs('#review-handoff-link').hidden = true;
|
||
qs('#review-handoff-link').href = item.url;
|
||
qs('#review-handoff-status').textContent = '';
|
||
qs('#review-submit-status').textContent = '';
|
||
qs('#submit-review').disabled = true;
|
||
closeInlineComposer();
|
||
draft = null;
|
||
reviewFiles = [];
|
||
selectedReviewHead = '';
|
||
qs('#close-review-sheet').focus();
|
||
try {
|
||
const detail = await reviewController.load(selectedReview);
|
||
if (selectedReview !== item) return;
|
||
qs('#review-sheet-body').textContent = detail.body || 'No description provided.';
|
||
qs('#review-ci-state').textContent = 'CI ' + (detail.ci_state || 'unknown');
|
||
qs('#review-files').innerHTML = (detail.files || []).length ? detail.files.map((file, index) =>
|
||
createReviewController.renderDiffFile(file, index, escapeHtml)
|
||
).join('') : '<div>No changed files reported.</div>';
|
||
document.querySelectorAll('.review-file-toggle').forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
const panel = document.getElementById(button.getAttribute('aria-controls'));
|
||
if (panel) createReviewController.toggleDiff(button, panel);
|
||
});
|
||
});
|
||
progress = createReviewController.createProgress({
|
||
storage: localStorage,
|
||
repository: item.repository,
|
||
number: item.number,
|
||
headSha: detail.head_sha || 'unknown',
|
||
files: detail.files || [],
|
||
});
|
||
reviewFiles = detail.files || [];
|
||
selectedReviewHead = detail.head_sha || '';
|
||
draft = createReviewController.createDraft({
|
||
storage: localStorage,
|
||
repository: item.repository,
|
||
number: item.number,
|
||
headSha: detail.head_sha || 'unknown',
|
||
files: reviewFiles,
|
||
});
|
||
const draftSnapshot = draft.snapshot();
|
||
qs('#review-decision').value = draftSnapshot.decision;
|
||
qs('#review-summary').value = draftSnapshot.summary;
|
||
document.querySelectorAll('.review-note').forEach(note => {
|
||
note.value = draftSnapshot.notes[note.dataset.reviewFilename] || '';
|
||
note.addEventListener('input', () => draft.setNote(note.dataset.reviewFilename, note.value));
|
||
});
|
||
document.querySelectorAll('.review-inline-target').forEach(target => {
|
||
const anchor = inlineAnchor(target);
|
||
target.classList.toggle('has-draft', draftSnapshot.comments.some(comment =>
|
||
sameInlineAnchor(comment, anchor)
|
||
));
|
||
target.addEventListener('click', () => openInlineComposer(target));
|
||
});
|
||
document.querySelectorAll('.review-mark').forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
const snapshot = progress.markReviewed(button.dataset.reviewFilename);
|
||
showReviewProgress(snapshot);
|
||
openNextUnreviewed(snapshot);
|
||
});
|
||
});
|
||
const progressSnapshot = progress.snapshot();
|
||
showReviewProgress(progressSnapshot);
|
||
openNextUnreviewed(progressSnapshot);
|
||
qs('#review-history').innerHTML = (detail.reviews || []).length ? detail.reviews.map(review =>
|
||
'<div class="review-history"><strong>' + escapeHtml(review.user?.login || 'Reviewer') + '</strong> · ' +
|
||
escapeHtml(review.state || 'commented') + (review.body ? '<div class="small">' + escapeHtml(review.body) + '</div>' : '') + '</div>'
|
||
).join('') : '<div>No prior reviews.</div>';
|
||
qs('#review-sheet-status').textContent = 'Ready to review · by ' + (detail.author || 'unknown author');
|
||
qs('#submit-review').disabled = false;
|
||
} catch (error) {
|
||
if (selectedReview !== item) return;
|
||
qs('#review-sheet-status').textContent = error.message + ' Retry here or use Open in Gitea.';
|
||
qs('#retry-review-load').hidden = false;
|
||
qs('#retry-review-load').focus();
|
||
}
|
||
}
|
||
|
||
function closeReviewSheet() {
|
||
qs('#review-sheet').classList.remove('open');
|
||
selectedReview = null;
|
||
progress = null;
|
||
draft = null;
|
||
reviewFiles = [];
|
||
selectedReviewHead = '';
|
||
closeInlineComposer();
|
||
if (reviewTrigger?.isConnected) reviewTrigger.focus();
|
||
}
|
||
|
||
function closeUpdateSheet(restoreTrigger = true) {
|
||
qs('#update-sheet').classList.remove('open');
|
||
selectedUpdate = null;
|
||
if (restoreTrigger && updateTrigger?.isConnected) updateTrigger.focus();
|
||
else qs('[data-work-filter="update"]')?.focus();
|
||
}
|
||
|
||
function markMyWorkStale() {
|
||
qs('#my-work').setAttribute('data-stale', 'true');
|
||
qs('#my-work-status').textContent = lastMyWork.length ?
|
||
'Update failed · showing last known work' : 'Work inbox unavailable.';
|
||
}
|
||
|
||
function markNotificationsStale() {
|
||
qs('#my-work').setAttribute('data-stale', 'true');
|
||
qs('#my-work-status').textContent = lastNotifications.length ?
|
||
'Unread updates unavailable · showing last known updates' :
|
||
'Unread updates unavailable · assigned work is fresh';
|
||
}
|
||
|
||
function paintDeltas(deltas) {
|
||
const el = qs('#ai');
|
||
el.innerHTML = deltas.length ? deltas.map(d => '<div class="suggestion ' + d.priority + '"><span class="pill">' + escapeHtml(d.priority) + '</span> <strong>' + escapeHtml(d.action) + '</strong> ' + escapeHtml(d.target || '') + '<div class="muted">' + escapeHtml(d.panel) + '</div></div>').join('') : '<div class="muted">No suggestions yet.</div>';
|
||
}
|
||
|
||
function paintEventStream(events) {
|
||
const el = qs('#gitea-events');
|
||
el.innerHTML = events.length ? events.slice(0, 12).map(event =>
|
||
'<div class="event"><strong>' + escapeHtml(event.actor?.login || 'Gitea') + '</strong> ' +
|
||
escapeHtml(event.type || 'activity') +
|
||
'<div class="small">' + escapeHtml(event.repo?.full_name || '') +
|
||
(event.created_at ? ' · ' + escapeHtml(fmt(event.created_at)) : '') + '</div></div>'
|
||
).join('') : '<div class="muted">No recent Gitea activity.</div>';
|
||
}
|
||
|
||
function setEventStreamStatus(message) {
|
||
qs('#gitea-events-status').textContent = message;
|
||
}
|
||
|
||
function renderLiveSnapshot(snapshot) {
|
||
const contextFreshness = snapshot.freshness?.sections?.context;
|
||
const eventsFreshness = snapshot.freshness?.sections?.events;
|
||
const notificationFreshness = snapshot.freshness?.sections?.notifications;
|
||
const hasNotifications = Array.isArray(snapshot.notifications);
|
||
const notificationsFresh = hasNotifications && !notificationFreshness?.stale;
|
||
if (hasNotifications) {
|
||
if (notificationPagination.page > 1) {
|
||
const byId = new Map(lastNotifications.map(item => [item.id, item]));
|
||
snapshot.notifications.forEach(item => byId.set(item.id, item));
|
||
lastNotifications = Array.from(byId.values());
|
||
} else {
|
||
lastNotifications = snapshot.notifications;
|
||
}
|
||
if (snapshot.notification_pagination) {
|
||
const page = notificationPagination.page > 1 ? notificationPagination.page :
|
||
snapshot.notification_pagination.page;
|
||
notificationPager.reset({
|
||
page,
|
||
total: snapshot.notification_pagination.total,
|
||
has_more: page * 50 < snapshot.notification_pagination.total,
|
||
});
|
||
}
|
||
}
|
||
if (snapshot.context) {
|
||
snapshot.context.notifications = lastNotifications;
|
||
renderContextSnapshot(snapshot.context);
|
||
if (contextFreshness?.stale) markMyWorkStale();
|
||
else if (!notificationsFresh) markNotificationsStale();
|
||
} else handleContextError(new Error('Context section unavailable'));
|
||
if (Array.isArray(snapshot.events)) paintEventStream(snapshot.events);
|
||
if (eventsFreshness?.revalidating) {
|
||
setEventStreamStatus('Refreshing activity · showing last activity');
|
||
} else if (eventsFreshness?.stale || eventsFreshness?.degraded) {
|
||
const retrySeconds = Number(eventsFreshness.retry_in_seconds) || 0;
|
||
setEventStreamStatus('Activity refresh failed · showing last activity' +
|
||
(retrySeconds > 0 ? ' · retrying in ' + retrySeconds + 's' : ''));
|
||
} else if (Array.isArray(snapshot.events)) {
|
||
setEventStreamStatus('Updated ' + fmt(new Date()));
|
||
} else {
|
||
setEventStreamStatus('Update failed · showing last activity');
|
||
}
|
||
// Compatibility fallback for snapshots produced before section metadata.
|
||
if (!eventsFreshness && snapshot.freshness?.degraded && !snapshot.freshness.revalidating) {
|
||
const retrySeconds = Number(snapshot.freshness.retry_in_seconds) || 0;
|
||
setEventStreamStatus('Refresh failed · showing last known data' +
|
||
(retrySeconds > 0 ? ' · retrying in ' + retrySeconds + 's' : ''));
|
||
} else if (!eventsFreshness && snapshot.freshness?.revalidating) {
|
||
setEventStreamStatus('Refreshing · showing recent snapshot');
|
||
}
|
||
}
|
||
|
||
|
||
function escapeHtml(s) { return String(s || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||
function escAttr(s) { return escapeHtml(s); }
|
||
function openModal(id) { qs('#' + id).classList.add('open'); }
|
||
function closeModal(id) { qs('#' + id).classList.remove('open'); }
|
||
|
||
/* Creative ambient background */
|
||
(function bg(){
|
||
const c=qs('#bg'),ctx=c.getContext('2d');
|
||
let w,h,time=0;
|
||
const resize=()=>{ w=c.width=innerWidth; h=c.height=innerHeight; };
|
||
resize(); addEventListener('resize', resize);
|
||
const draw=()=>{
|
||
time+=0.012;
|
||
ctx.clearRect(0,0,w,h);
|
||
ctx.strokeStyle='#1f3a5f'; ctx.lineWidth=1;
|
||
const step=60;
|
||
for(let x=0;x<w;x+=step){ ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,h); ctx.stroke(); }
|
||
for(let y=0;y<h;y+=step){ ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(w,y); ctx.stroke(); }
|
||
const cx=w/2+Math.sin(time*0.31)*90, cy=h/2+Math.cos(time*0.37)*70;
|
||
const grad=ctx.createRadialGradient(cx,cy,20,cx,cy,260);
|
||
grad.addColorStop(0,'rgba(96,165,250,.28)'); grad.addColorStop(1,'rgba(96,165,250,0)');
|
||
ctx.fillStyle=grad; ctx.fillRect(0,0,w,h);
|
||
requestAnimationFrame(draw);
|
||
};
|
||
draw();
|
||
})();
|
||
|
||
/* Whiteboard */
|
||
function initWhiteboard() {
|
||
const canvas = qs('#wb'), ctx = canvas.getContext('2d');
|
||
let drawing = false;
|
||
function resize() { const dpr = window.devicePixelRatio||1; const r=canvas.getBoundingClientRect(); canvas.width=r.width*dpr; canvas.height=r.height*dpr; ctx.setTransform(dpr,0,0,dpr,0,0); ctx.lineCap='round'; ctx.lineJoin='round'; ctx.strokeStyle='#e5e7eb'; ctx.lineWidth=2; }
|
||
resize(); addEventListener('resize', resize);
|
||
const p = (e) => ({ x: e.clientX - canvas.getBoundingClientRect().left, y: e.clientY - canvas.getBoundingClientRect().top });
|
||
canvas.addEventListener('pointerdown', (e)=>{ drawing=true; const pos=p(e); ctx.beginPath(); ctx.moveTo(pos.x, pos.y); });
|
||
canvas.addEventListener('pointermove', (e)=>{ if (!drawing) return; const pos=p(e); ctx.lineTo(pos.x, pos.y); ctx.stroke(); });
|
||
canvas.addEventListener('pointerup', () => drawing=false);
|
||
canvas.addEventListener('pointerleave', () => drawing=false);
|
||
qs('#wb-clear').addEventListener('click', () => { const r=canvas.getBoundingClientRect(); ctx.clearRect(0,0,r.width,r.height); });
|
||
qs('#wb-save').addEventListener('click', () => { const a=document.createElement('a'); a.href=canvas.toDataURL(); a.download='whiteboard.png'; a.click(); });
|
||
}
|
||
|
||
/* Markdown */
|
||
qs('#md-input').addEventListener('input', renderMD);
|
||
function renderMD() {
|
||
const raw = qs('#md-input').value || '';
|
||
qs('#md-preview').innerHTML = '<pre>' + escapeHtml(raw) + '</pre><div style="margin-top:8px;">' + renderMarkdown(raw) + '</div>';
|
||
}
|
||
|
||
/* Commands */
|
||
const commands = [
|
||
{ name: 'Open whiteboard', run: () => { openModal('whiteboard-modal'); initWhiteboard(); } },
|
||
{ name: 'Open markdown widget', run: () => { qs('#md-input').focus(); } },
|
||
{ name: 'Refresh now', run: load },
|
||
{ name: 'Scroll issues', run: () => qs('#work').scrollIntoView({ behavior:'smooth', block:'start' }) },
|
||
];
|
||
function renderCommands(filter) {
|
||
const el = qs('#cmd-results');
|
||
const items = filterCommands(commands, filter);
|
||
el.innerHTML = items.map((c, idx) => '<div class="cmd-item" data-idx="' + idx + '">' + escapeHtml(c.name) + '</div>').join('');
|
||
el.querySelectorAll('.cmd-item').forEach((item) => {
|
||
item.addEventListener('click', () => { items[Number(item.dataset.idx)].run(); qs('#cmd-palette').classList.remove('open'); qs('#cmd-input').value=''; });
|
||
});
|
||
}
|
||
qs('#open-palette').addEventListener('click', () => { qs('#cmd-palette').classList.add('open'); qs('#cmd-input').focus(); renderCommands(''); });
|
||
qs('#cmd-input').addEventListener('input', (e) => renderCommands(e.target.value));
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape' && creatingIssue) {
|
||
e.preventDefault();
|
||
saveIssueCaptureDraft();
|
||
closeCreateIssueSheet();
|
||
return;
|
||
}
|
||
if (e.key === 'Escape' && selectedIssue) {
|
||
e.preventDefault();
|
||
closeIssueSheet();
|
||
return;
|
||
}
|
||
if (e.key === 'Escape' && selectedPull) {
|
||
e.preventDefault();
|
||
closePullSheet();
|
||
return;
|
||
}
|
||
if ((e.metaKey||e.ctrlKey) && e.key==='k') {
|
||
e.preventDefault();
|
||
qs('#cmd-palette').classList.toggle('open');
|
||
if (qs('#cmd-palette').classList.contains('open')) {
|
||
qs('#cmd-input').focus();
|
||
renderCommands('');
|
||
}
|
||
}
|
||
});
|
||
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
|
||
qs('#new-issue').addEventListener('click', openCreateIssueSheet);
|
||
qs('#cancel-new-issue').addEventListener('click', () => {
|
||
saveIssueCaptureDraft();
|
||
closeCreateIssueSheet();
|
||
});
|
||
['#create-issue-title', '#create-issue-body'].forEach(selector =>
|
||
qs(selector).addEventListener('input', saveIssueCaptureDraft)
|
||
);
|
||
qs('#create-issue-repository').addEventListener('change', event => {
|
||
loadIssueLabels(event.target.value);
|
||
saveIssueCaptureDraft();
|
||
});
|
||
qs('#create-issue-label-list').addEventListener('change', saveIssueCaptureDraft);
|
||
qs('#create-issue-form').addEventListener('submit', async event => {
|
||
event.preventDefault();
|
||
const captureDraft = {
|
||
repository: qs('#create-issue-repository').value,
|
||
title: qs('#create-issue-title').value.trim(),
|
||
body: qs('#create-issue-body').value.trim(),
|
||
labelIds: selectedIssueLabelIds(),
|
||
};
|
||
if (!captureDraft.repository || !captureDraft.title) {
|
||
qs('#create-issue-status').textContent = 'Choose a repository and add a title.';
|
||
qs('#create-issue-title').focus();
|
||
return;
|
||
}
|
||
const button = qs('#submit-new-issue');
|
||
button.disabled = true;
|
||
qs('#create-issue-status').textContent = 'Creating issue…';
|
||
try {
|
||
const confirmed = await issueCapture.submit(captureDraft);
|
||
lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []);
|
||
lastMyWork = buildMyWork(lastContextSnapshot);
|
||
const created = lastMyWork.find(item =>
|
||
item.kind === 'issue' && item.repository === confirmed.repository && item.number === confirmed.number
|
||
);
|
||
closeCreateIssueSheet();
|
||
refreshMyWorkView();
|
||
qs('#my-work-action-status').textContent = created.key + ' created and assigned to you.';
|
||
openIssueSheet(created, qs('#new-issue'));
|
||
} catch (error) {
|
||
qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.';
|
||
button.disabled = false;
|
||
qs('#create-issue-title').focus();
|
||
}
|
||
});
|
||
qs('#close-issue-sheet').addEventListener('click', closeIssueSheet);
|
||
qs('#retry-issue-load').addEventListener('click', () => {
|
||
if (selectedIssue) openIssueSheet(selectedIssue, issueTrigger);
|
||
});
|
||
qs('#issue-comment').addEventListener('input', event => {
|
||
if (selectedIssue) issueController.saveDraft(selectedIssue, event.target.value);
|
||
});
|
||
qs('#send-issue-comment').addEventListener('click', async () => {
|
||
if (!selectedIssue) return;
|
||
const body = qs('#issue-comment').value.trim();
|
||
if (!body) {
|
||
qs('#issue-comment-status').textContent = 'Write a comment before posting.';
|
||
qs('#issue-comment').focus();
|
||
return;
|
||
}
|
||
const button = qs('#send-issue-comment');
|
||
button.disabled = true;
|
||
qs('#issue-comment-status').textContent = 'Posting comment…';
|
||
try {
|
||
const comment = await issueController.comment(selectedIssue, body);
|
||
const empty = qs('#issue-comments .muted');
|
||
if (empty) empty.remove();
|
||
qs('#issue-comments').insertAdjacentHTML('beforeend', renderIssueComment(comment));
|
||
qs('#issue-comment').value = '';
|
||
qs('#issue-comment-status').textContent = 'Comment posted.';
|
||
} catch (error) {
|
||
qs('#issue-comment-status').textContent = error.message + ' Your draft is safe; retry.';
|
||
qs('#issue-comment').focus();
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
});
|
||
qs('#close-issue').addEventListener('click', async () => {
|
||
if (!selectedIssue || !window.confirm('Close ' + selectedIssue.key + '?')) return;
|
||
const closing = selectedIssue;
|
||
const button = qs('#close-issue');
|
||
button.disabled = true;
|
||
qs('#issue-sheet-status').textContent = 'Closing issue…';
|
||
try {
|
||
await issueController.close(selectedIssue);
|
||
lastMyWork = lastMyWork.filter(item =>
|
||
!(item.kind === 'issue' && item.repository === closing.repository && item.number === closing.number)
|
||
);
|
||
closeIssueSheet();
|
||
refreshMyWorkView();
|
||
qs('#my-work-action-status').textContent = closing.key + ' closed.';
|
||
} catch (error) {
|
||
qs('#issue-sheet-status').textContent = error.message + ' The issue remains in My Work; retry.';
|
||
button.disabled = false;
|
||
button.focus();
|
||
}
|
||
});
|
||
qs('#close-pull-sheet').addEventListener('click', closePullSheet);
|
||
qs('#retry-pull-load').addEventListener('click', () => {
|
||
if (selectedPull) openPullSheet(selectedPull, pullTrigger);
|
||
});
|
||
qs('#pull-comment').addEventListener('input', event => {
|
||
if (selectedPull) pullController.saveDraft(selectedPull, event.target.value);
|
||
});
|
||
qs('#send-pull-comment').addEventListener('click', async () => {
|
||
if (!selectedPull) return;
|
||
const body = qs('#pull-comment').value.trim();
|
||
if (!body) {
|
||
qs('#pull-comment-status').textContent = 'Write a comment before posting.';
|
||
qs('#pull-comment').focus();
|
||
return;
|
||
}
|
||
const button = qs('#send-pull-comment');
|
||
button.disabled = true;
|
||
qs('#pull-comment-status').textContent = 'Posting comment…';
|
||
try {
|
||
const comment = await pullController.comment(selectedPull, body);
|
||
qs('#pull-comments .muted')?.remove();
|
||
qs('#pull-comments').insertAdjacentHTML('beforeend', '<div class="pull-comment-card">' + renderIssueComment(comment) + '</div>');
|
||
qs('#pull-comment').value = '';
|
||
qs('#pull-comment-status').textContent = 'Comment posted.';
|
||
} catch (error) {
|
||
qs('#pull-comment-status').textContent = error.message + ' Your draft is safe; retry.';
|
||
qs('#pull-comment').focus();
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
});
|
||
qs('#merge-pull').addEventListener('click', async () => {
|
||
if (!selectedPull || !selectedPullDetail?.head_sha || !window.confirm('Merge ' + selectedPull.key + ' at current head?')) return;
|
||
const merging = selectedPull;
|
||
const button = qs('#merge-pull');
|
||
button.disabled = true;
|
||
qs('#pull-sheet-status').textContent = 'Merging pull request…';
|
||
try {
|
||
await pullController.merge(selectedPull, selectedPullDetail.head_sha);
|
||
lastMyWork = lastMyWork.filter(item =>
|
||
!(item.kind === 'pull' && item.repository === merging.repository && item.number === merging.number)
|
||
);
|
||
closePullSheet();
|
||
refreshMyWorkView();
|
||
qs('#my-work-action-status').textContent = merging.key + ' merged.';
|
||
} catch (error) {
|
||
qs('#pull-sheet-status').textContent = error.message + ' The pull request remains in My Work; refresh and retry.';
|
||
button.disabled = false;
|
||
button.focus();
|
||
}
|
||
});
|
||
qs('#keep-update-unread').addEventListener('click', () => closeUpdateSheet(true));
|
||
qs('#retry-update-load').addEventListener('click', () => {
|
||
if (selectedUpdate) notificationReader.open(selectedUpdate, lastMyWork);
|
||
});
|
||
qs('#update-reply').addEventListener('input', event => {
|
||
if (selectedUpdate) notificationReplier.saveDraft(selectedUpdate, event.target.value);
|
||
});
|
||
qs('#send-update-reply').addEventListener('click', async () => {
|
||
if (!selectedUpdate) return;
|
||
const body = qs('#update-reply').value.trim();
|
||
if (!body) {
|
||
qs('#update-reply-status').textContent = 'Write a reply before sending.';
|
||
qs('#update-reply').focus();
|
||
return;
|
||
}
|
||
qs('#send-update-reply').disabled = true;
|
||
const result = await notificationReplier.submit(selectedUpdate, body);
|
||
qs('#send-update-reply').disabled = false;
|
||
if (result) {
|
||
qs('#update-reply').value = '';
|
||
qs('#mark-update-read-next').focus();
|
||
} else {
|
||
qs('#update-reply').focus();
|
||
}
|
||
});
|
||
qs('#mark-update-read-next').addEventListener('click', async () => {
|
||
qs('#mark-update-read-next').disabled = true;
|
||
try {
|
||
await notificationReader.markReadAndNext(lastMyWork);
|
||
} finally {
|
||
qs('#mark-update-read-next').disabled = false;
|
||
}
|
||
});
|
||
qs('#close-review-sheet').addEventListener('click', closeReviewSheet);
|
||
qs('#retry-review-load').addEventListener('click', () => {
|
||
if (selectedReview) openReviewSheet(selectedReview, reviewTrigger);
|
||
});
|
||
qs('#next-unreviewed-review').addEventListener('click', () => {
|
||
if (progress) openNextUnreviewed(progress.snapshot());
|
||
});
|
||
qs('#review-summary').addEventListener('input', event => draft?.setSummary(event.target.value));
|
||
qs('#review-decision').addEventListener('change', event => draft?.setDecision(event.target.value));
|
||
qs('#save-inline-comment').addEventListener('click', () => {
|
||
if (!draft || !activeInlineTarget) return;
|
||
const body = qs('#review-inline-body').value.trim();
|
||
if (!body) {
|
||
qs('#review-inline-body').focus();
|
||
return;
|
||
}
|
||
draft.setInlineComment(inlineAnchor(activeInlineTarget), body);
|
||
activeInlineTarget.classList.add('has-draft');
|
||
const target = activeInlineTarget;
|
||
closeInlineComposer();
|
||
target.focus();
|
||
});
|
||
qs('#delete-inline-comment').addEventListener('click', () => {
|
||
if (!draft || !activeInlineTarget) return;
|
||
draft.removeInlineComment(inlineAnchor(activeInlineTarget));
|
||
activeInlineTarget.classList.remove('has-draft');
|
||
const target = activeInlineTarget;
|
||
closeInlineComposer();
|
||
target.focus();
|
||
});
|
||
qs('#cancel-inline-comment').addEventListener('click', () => {
|
||
const target = activeInlineTarget;
|
||
closeInlineComposer();
|
||
target?.focus();
|
||
});
|
||
qs('#submit-review').addEventListener('click', async () => {
|
||
if (!draft || !selectedReview || !selectedReviewHead) return;
|
||
const snapshot = draft.snapshot();
|
||
const labels = { approve: 'Approve', request_changes: 'Request changes' };
|
||
if (labels[snapshot.decision] && !window.confirm(
|
||
labels[snapshot.decision] + ' ' + selectedReview.repository + '#' + selectedReview.number + '?'
|
||
)) return;
|
||
const button = qs('#submit-review');
|
||
button.disabled = true;
|
||
qs('#review-submit-status').textContent = 'Submitting review…';
|
||
try {
|
||
const result = await reviewController.submit(selectedReview, {
|
||
decision: snapshot.decision,
|
||
body: createReviewController.formatFeedback(snapshot, reviewFiles),
|
||
expected_head_sha: selectedReviewHead,
|
||
comments: snapshot.comments,
|
||
});
|
||
draft.clear();
|
||
progress?.clear();
|
||
qs('#review-decision').value = 'comment';
|
||
qs('#review-summary').value = '';
|
||
document.querySelectorAll('.review-note').forEach(note => { note.value = ''; });
|
||
if (progress) showReviewProgress(progress.snapshot());
|
||
qs('#review-submit-status').textContent = 'Review submitted · ' + (result.state || 'complete') + '.';
|
||
await load();
|
||
button.focus();
|
||
} catch (error) {
|
||
qs('#review-submit-status').textContent = error.message + ' Your draft is safe; retry or open in Gitea.';
|
||
button.disabled = false;
|
||
button.focus();
|
||
}
|
||
});
|
||
qs('#copy-review-feedback').addEventListener('click', async () => {
|
||
if (!draft || !selectedReview || reviewHandoffPending) return;
|
||
reviewHandoffPending = true;
|
||
qs('#copy-review-feedback').disabled = true;
|
||
const fallback = qs('#review-copy-fallback');
|
||
const directLink = qs('#review-handoff-link');
|
||
fallback.hidden = true;
|
||
directLink.hidden = true;
|
||
let handoffWindow = null;
|
||
try {
|
||
const result = await createReviewController.copyAndContinue({
|
||
text: createReviewController.formatFeedback(draft.snapshot(), reviewFiles),
|
||
url: selectedReview.url,
|
||
copy: text => navigator.clipboard.writeText(text),
|
||
open: () => {
|
||
handoffWindow = window.open('about:blank', '_blank');
|
||
if (handoffWindow) handoffWindow.opener = null;
|
||
return handoffWindow;
|
||
},
|
||
fallback: text => {
|
||
fallback.value = text;
|
||
fallback.hidden = false;
|
||
fallback.focus();
|
||
fallback.select();
|
||
},
|
||
});
|
||
if (result.opened) {
|
||
qs('#review-handoff-status').textContent = 'Feedback copied. Gitea opened in a new tab.';
|
||
} else {
|
||
directLink.hidden = false;
|
||
qs('#review-handoff-status').textContent = result.copied ?
|
||
'Feedback copied. Your browser blocked the new tab; continue with the link below.' :
|
||
'Clipboard unavailable. Copy the selected feedback, then continue to Gitea.';
|
||
}
|
||
} finally {
|
||
reviewHandoffPending = false;
|
||
qs('#copy-review-feedback').disabled = false;
|
||
}
|
||
});
|
||
|
||
const contextPoller = createContextPoller({
|
||
fetchContext: fetchLiveSnapshot,
|
||
onSnapshot: renderLiveSnapshot,
|
||
onError: error => {
|
||
handleContextError(error);
|
||
setEventStreamStatus('Update failed · showing last activity');
|
||
},
|
||
isHidden: () => document.hidden,
|
||
intervalMs: 8000,
|
||
});
|
||
function load() { return contextPoller.refresh(); }
|
||
|
||
qs('#refresh').addEventListener('click', load);
|
||
qs('#load-more-notifications').addEventListener('click', () =>
|
||
notificationPager.loadMore(lastNotifications)
|
||
);
|
||
qs('#load-more-work').addEventListener('click', async () => {
|
||
const stream = activeWorkStreams().find(item => workPagination[item]?.has_more);
|
||
if (!stream || !lastContextSnapshot) return;
|
||
const button = qs('#load-more-work');
|
||
button.disabled = true;
|
||
const existing = stream === 'issue' ?
|
||
(lastContextSnapshot.issues || []) : (lastContextSnapshot.pull_requests || []);
|
||
try {
|
||
const loaded = await workPager.loadMore(stream, existing);
|
||
if (loaded) document.querySelector('.my-work-card:last-child .my-work-card-main')?.focus();
|
||
else button.focus();
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
});
|
||
qs('#bulk-mark-read').addEventListener('click', async () => {
|
||
const allIds = notificationIds(filterMyWork(lastMyWork, 'update'));
|
||
const ids = allIds.slice(0, 50);
|
||
if (!ids.length || bulkMarkPending) return;
|
||
if (!bulkConfirmationPending) {
|
||
bulkConfirmationPending = true;
|
||
qs('#my-work-action-status').textContent = 'Confirm to mark all visible updates read.';
|
||
renderMyWork();
|
||
return;
|
||
}
|
||
bulkConfirmationPending = false;
|
||
bulkMarkPending = true;
|
||
renderMyWork();
|
||
const result = await bulkNotificationAcknowledger.acknowledge(lastMyWork, ids);
|
||
if (result) {
|
||
const marked = new Set(result.marked);
|
||
lastNotifications = lastNotifications.filter(item => !marked.has(item.id));
|
||
}
|
||
bulkMarkPending = false;
|
||
renderMyWork();
|
||
(document.querySelector('[data-notification-id]') || qs('[data-work-filter="update"]'))?.focus();
|
||
});
|
||
document.querySelectorAll('[data-work-filter]').forEach(button => {
|
||
button.setAttribute('aria-pressed', String(button.dataset.workFilter === selectedWorkFilter));
|
||
button.addEventListener('click', () => {
|
||
selectedWorkFilter = button.dataset.workFilter;
|
||
try {
|
||
sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter);
|
||
} catch (e) {
|
||
console.warn('Could not persist My Work filter', e);
|
||
}
|
||
document.querySelectorAll('[data-work-filter]').forEach(item =>
|
||
item.setAttribute('aria-pressed', String(item === button))
|
||
);
|
||
renderMyWork();
|
||
updateWorkPaginationControls();
|
||
});
|
||
});
|
||
contextPoller.start();
|
||
document.addEventListener('visibilitychange', () => {
|
||
contextPoller.setVisible(!document.hidden);
|
||
});
|
||
|
||
/* Widgets */
|
||
function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); }
|
||
setInterval(widgetTick, 1000);
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|