288 lines
10 KiB
JavaScript
288 lines
10 KiB
JavaScript
/**
|
|
* Git Board — Surface Entry Point
|
|
*
|
|
* Kanban board for Gitea/GitHub issues and PRs.
|
|
* Calls Starlark backend via /s/git-board/api/*.
|
|
* Per-user API token via extension settings.
|
|
*/
|
|
(async function () {
|
|
'use strict';
|
|
|
|
var mount = document.getElementById('extension-mount');
|
|
if (!mount) return;
|
|
|
|
var base = window.__BASE__ || '';
|
|
var ver = window.__VERSION__ || '0';
|
|
var slug = 'git-board';
|
|
|
|
// ── Boot SDK ───────────────────────────────
|
|
try {
|
|
if (!window.preact) {
|
|
var { h, render } = await import(base + '/js/sw/vendor/preact.module.js');
|
|
var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js');
|
|
var htmModule = await import(base + '/js/sw/vendor/htm.module.js');
|
|
window.preact = { h, render };
|
|
window.hooks = hooksModule;
|
|
window.html = htmModule.default.bind(h);
|
|
}
|
|
var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver);
|
|
await sdk.boot();
|
|
} catch (e) {
|
|
mount.innerHTML = '<p style="color:var(--danger);padding:24px;">SDK boot failed: ' + e.message + '</p>';
|
|
return;
|
|
}
|
|
|
|
var { html } = window;
|
|
var { useState, useEffect, useCallback, useRef } = hooks;
|
|
var { render } = preact;
|
|
|
|
var API = base + '/s/' + slug + '/api';
|
|
|
|
async function api(path) {
|
|
var token = sw.auth._getToken();
|
|
var resp = await fetch(API + path, {
|
|
headers: { 'Authorization': 'Bearer ' + token }
|
|
});
|
|
var text = await resp.text();
|
|
try { return JSON.parse(text); } catch (_) { return { error: text }; }
|
|
}
|
|
|
|
// ── App ────────────────────────────────────
|
|
|
|
function App() {
|
|
var [owner, setOwner] = useState('');
|
|
var [repo, setRepo] = useState('');
|
|
var [repos, setRepos] = useState([]);
|
|
var [board, setBoard] = useState(null);
|
|
var [loading, setLoading] = useState(false);
|
|
var [needsToken, setNeedsToken] = useState(false);
|
|
var menuRef = useRef(null);
|
|
|
|
// Mount user menu
|
|
useEffect(function () {
|
|
if (menuRef.current && sw.userMenu) {
|
|
sw.userMenu(menuRef.current, { placement: 'down-right' });
|
|
}
|
|
}, []);
|
|
|
|
// Load repos on mount
|
|
useEffect(function () {
|
|
api('/repos').then(function (d) {
|
|
if (d.error && d.error.indexOf('failed') !== -1) {
|
|
setNeedsToken(true);
|
|
return;
|
|
}
|
|
setRepos(d.data || []);
|
|
if (d.data && d.data.length > 0 && !owner) {
|
|
setOwner(d.data[0].owner);
|
|
setRepo(d.data[0].name);
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
var loadBoard = useCallback(function () {
|
|
if (!owner || !repo) return;
|
|
setLoading(true);
|
|
api('/board?owner=' + encodeURIComponent(owner) + '&repo=' + encodeURIComponent(repo))
|
|
.then(function (d) {
|
|
if (d.error) {
|
|
sw.toast(d.error, 'error');
|
|
if (d.error.indexOf('failed') !== -1) setNeedsToken(true);
|
|
} else {
|
|
setBoard(d);
|
|
setNeedsToken(false);
|
|
}
|
|
setLoading(false);
|
|
});
|
|
}, [owner, repo]);
|
|
|
|
useEffect(function () { loadBoard(); }, [loadBoard]);
|
|
|
|
if (needsToken) return html`
|
|
<${TokenSetup} menuRef=${menuRef} />
|
|
`;
|
|
|
|
return html`
|
|
<div class="gb-shell">
|
|
<header class="gb-header">
|
|
<div class="gb-header__left">
|
|
<h1 class="gb-title">Git Board</h1>
|
|
<${RepoPicker} repos=${repos} owner=${owner} repo=${repo}
|
|
onSelect=${function (o, r) { setOwner(o); setRepo(r); }} />
|
|
</div>
|
|
<div class="gb-header__right">
|
|
<button class="btn-small" onClick=${loadBoard} disabled=${loading}>
|
|
${loading ? '↻' : 'Refresh'}
|
|
</button>
|
|
<div ref=${menuRef}></div>
|
|
</div>
|
|
</header>
|
|
|
|
${board ? html`<${Board} data=${board} />` : html`
|
|
<div class="gb-empty">${loading ? 'Loading…' : 'Select a repository'}</div>
|
|
`}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ── Token Setup ────────────────────────────
|
|
|
|
function TokenSetup({ menuRef }) {
|
|
var [token, setToken] = useState('');
|
|
var [saving, setSaving] = useState(false);
|
|
|
|
async function save() {
|
|
if (!token.trim()) return;
|
|
setSaving(true);
|
|
try {
|
|
await sw.api.extensions.updateUserSettings('git-board', { api_token: token.trim() });
|
|
sw.toast('Token saved — reloading', 'success');
|
|
setTimeout(function () { location.reload(); }, 500);
|
|
} catch (e) {
|
|
sw.toast(e.message || 'Failed to save', 'error');
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
return html`
|
|
<div class="gb-shell">
|
|
<header class="gb-header">
|
|
<div class="gb-header__left"><h1 class="gb-title">Git Board</h1></div>
|
|
<div class="gb-header__right"><div ref=${menuRef}></div></div>
|
|
</header>
|
|
<div class="gb-setup">
|
|
<h2>Connect Your Account</h2>
|
|
<p>Enter your personal access token for Gitea or GitHub.<br/>
|
|
The admin configures the platform URL. You provide your own token.</p>
|
|
<div class="gb-setup__form">
|
|
<input type="password" class="gb-setup__input"
|
|
placeholder="ghp_... or gitea token"
|
|
value=${token} onInput=${function (e) { setToken(e.target.value); }} />
|
|
<button class="btn-small btn-primary" onClick=${save} disabled=${saving || !token.trim()}>
|
|
${saving ? 'Saving…' : 'Save Token'}
|
|
</button>
|
|
</div>
|
|
<p class="gb-setup__hint">Token is stored in your personal extension settings — not shared with other users.</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ── Repo Picker ────────────────────────────
|
|
|
|
function RepoPicker({ repos, owner, repo, onSelect }) {
|
|
var current = owner + '/' + repo;
|
|
return html`
|
|
<select class="gb-repo-picker" value=${current}
|
|
onChange=${function (e) {
|
|
var parts = e.target.value.split('/');
|
|
onSelect(parts[0], parts.slice(1).join('/'));
|
|
}}>
|
|
${repos.map(function (r) {
|
|
return html`<option key=${r.full_name} value=${r.full_name}>${r.full_name}</option>`;
|
|
})}
|
|
</select>
|
|
`;
|
|
}
|
|
|
|
// ── Kanban Board ───────────────────────────
|
|
|
|
var COLUMNS = [
|
|
{ key: 'open', title: 'Open', filter: function (i) { return i.state === 'open'; } },
|
|
{ key: 'in_progress', title: 'In Progress', filter: function (i) { return i.assignee && i.state === 'open'; } },
|
|
{ key: 'prs', title: 'Pull Requests', isPR: true },
|
|
{ key: 'closed', title: 'Done', filter: function (i) { return i.state === 'closed'; } },
|
|
];
|
|
|
|
function Board({ data }) {
|
|
var issues = data.issues || [];
|
|
var prs = data.pull_requests || [];
|
|
|
|
// Split issues: unassigned open vs assigned open
|
|
var openUnassigned = issues.filter(function (i) { return i.state === 'open' && !i.assignee; });
|
|
var inProgress = issues.filter(function (i) { return i.state === 'open' && !!i.assignee; });
|
|
|
|
return html`
|
|
<div class="gb-board">
|
|
<${Column} title="Open" count=${openUnassigned.length}>
|
|
${openUnassigned.map(function (i) { return html`<${IssueCard} key=${i.number} issue=${i} />`; })}
|
|
<//>
|
|
<${Column} title="In Progress" count=${inProgress.length}>
|
|
${inProgress.map(function (i) { return html`<${IssueCard} key=${i.number} issue=${i} />`; })}
|
|
<//>
|
|
<${Column} title="Pull Requests" count=${prs.length}>
|
|
${prs.map(function (p) { return html`<${PRCard} key=${p.number} pr=${p} />`; })}
|
|
<//>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function Column({ title, count, children }) {
|
|
return html`
|
|
<div class="gb-column">
|
|
<div class="gb-column__header">
|
|
<span class="gb-column__title">${title}</span>
|
|
<span class="gb-column__count">${count || 0}</span>
|
|
</div>
|
|
<div class="gb-column__cards">${children}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function IssueCard({ issue }) {
|
|
return html`
|
|
<a class="gb-card" href=${issue.html_url} target="_blank" rel="noopener">
|
|
<div class="gb-card__header">
|
|
<span class="gb-card__number">#${issue.number}</span>
|
|
${issue.assignee && html`<span class="gb-card__assignee">@${esc(issue.assignee)}</span>`}
|
|
</div>
|
|
<div class="gb-card__title">${esc(issue.title)}</div>
|
|
<div class="gb-card__labels">
|
|
${(issue.labels || []).map(function (l) {
|
|
return html`<span key=${l} class="badge">${esc(l)}</span>`;
|
|
})}
|
|
</div>
|
|
</a>
|
|
`;
|
|
}
|
|
|
|
function PRCard({ pr }) {
|
|
return html`
|
|
<a class="gb-card gb-card--pr" href=${pr.html_url} target="_blank" rel="noopener">
|
|
<div class="gb-card__header">
|
|
<span class="gb-card__number">#${pr.number}</span>
|
|
<span class="gb-card__branch">${esc(pr.head)} → ${esc(pr.base)}</span>
|
|
</div>
|
|
<div class="gb-card__title">${esc(pr.title)}</div>
|
|
<div class="gb-card__meta">
|
|
<span>@${esc(pr.user)}</span>
|
|
<span>${timeAgo(pr.created_at)}</span>
|
|
</div>
|
|
</a>
|
|
`;
|
|
}
|
|
|
|
// ── Utilities ──────────────────────────────
|
|
|
|
function esc(s) {
|
|
var el = document.createElement('span');
|
|
el.textContent = String(s || '');
|
|
return el.innerHTML;
|
|
}
|
|
|
|
function timeAgo(iso) {
|
|
if (!iso) return '';
|
|
var sec = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
|
if (sec < 60) return 'now';
|
|
var min = Math.floor(sec / 60);
|
|
if (min < 60) return min + 'm';
|
|
var hr = Math.floor(min / 60);
|
|
if (hr < 24) return hr + 'h';
|
|
return Math.floor(hr / 24) + 'd';
|
|
}
|
|
|
|
// ── Mount ──────────────────────────────────
|
|
render(html`<${App} />`, mount);
|
|
console.log('[git-board] Surface mounted');
|
|
})();
|