React frontend: PR dashboard, per-check dropdowns, timeline, themes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgBEW6qAwgn2fcQbA2f4ZQ
This commit is contained in:
parent
1163357967
commit
c9d810c8e5
49
server/index.ts
Normal file
49
server/index.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { loadConfig } from './config.js';
|
||||||
|
import { Db } from './db.js';
|
||||||
|
import { runPoll } from './poller.js';
|
||||||
|
import { getLastRateLimit } from './github/client.js';
|
||||||
|
import { createApp, buildSyncPayload } from './routes.js';
|
||||||
|
import { broadcast } from './sse.js';
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const db = new Db();
|
||||||
|
|
||||||
|
let polling = false;
|
||||||
|
|
||||||
|
async function poll(): Promise<void> {
|
||||||
|
if (polling) return;
|
||||||
|
polling = true;
|
||||||
|
try {
|
||||||
|
const result = await runPoll(config, db);
|
||||||
|
console.log(
|
||||||
|
`[poll] ${result.prCount} PRs, ${result.changedPrIds.length} changed, ${result.newEventCount} new events`,
|
||||||
|
);
|
||||||
|
broadcast(buildSyncPayload(db, result.changedPrIds, result.newEventCount));
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error(`[poll] failed: ${message}`);
|
||||||
|
db.setMeta('last_sync_error', message);
|
||||||
|
broadcast(buildSyncPayload(db));
|
||||||
|
} finally {
|
||||||
|
polling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleNext(): void {
|
||||||
|
// Back off to 5 minutes when the rate-limit budget runs low.
|
||||||
|
const remaining = getLastRateLimit()?.remaining;
|
||||||
|
const intervalMs =
|
||||||
|
remaining !== undefined && remaining < 500 ? 5 * 60_000 : config.pollIntervalSeconds * 1000;
|
||||||
|
setTimeout(async () => {
|
||||||
|
await poll();
|
||||||
|
scheduleNext();
|
||||||
|
}, intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = createApp({ db, triggerSync: poll });
|
||||||
|
app.listen(config.port, () => {
|
||||||
|
console.log(`pr-monitor listening on http://localhost:${config.port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await poll();
|
||||||
|
scheduleNext();
|
||||||
86
server/routes.ts
Normal file
86
server/routes.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import express, { type Express } from 'express';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { StatePayload, SyncEventPayload } from '../shared/types.js';
|
||||||
|
import type { Db } from './db.js';
|
||||||
|
import { rootDir } from './config.js';
|
||||||
|
import { addClient } from './sse.js';
|
||||||
|
|
||||||
|
export interface RouteDeps {
|
||||||
|
db: Db;
|
||||||
|
triggerSync: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSyncPayload(db: Db, changedPrIds: string[] = [], newEventCount = 0): SyncEventPayload {
|
||||||
|
return {
|
||||||
|
lastSyncAt: db.getMeta('last_sync_at'),
|
||||||
|
error: db.getMeta('last_sync_error'),
|
||||||
|
changedPrIds,
|
||||||
|
newEventCount,
|
||||||
|
unreadCount: db.unreadCounts().total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createApp({ db, triggerSync }: RouteDeps): Express {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
app.get('/api/state', (_req, res) => {
|
||||||
|
const { byPr, total } = db.unreadCounts();
|
||||||
|
const payload: StatePayload = {
|
||||||
|
prs: db.getOpenSnapshots(),
|
||||||
|
lastSyncAt: db.getMeta('last_sync_at'),
|
||||||
|
lastSyncError: db.getMeta('last_sync_error'),
|
||||||
|
unreadCountsByPr: byPr,
|
||||||
|
unreadTotal: total,
|
||||||
|
};
|
||||||
|
res.json(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/timeline', (req, res) => {
|
||||||
|
const limit = Math.min(Number(req.query.limit) || 50, 200);
|
||||||
|
const before = typeof req.query.before === 'string' ? req.query.before : undefined;
|
||||||
|
const unreadOnly = req.query.unread === '1';
|
||||||
|
res.json(db.timeline({ limit, before, unreadOnly }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/timeline/:id/read', (req, res) => {
|
||||||
|
db.markRead(req.params.id, true, new Date().toISOString());
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/timeline/:id/unread', (req, res) => {
|
||||||
|
db.markRead(req.params.id, false, new Date().toISOString());
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/timeline/read-all', (req, res) => {
|
||||||
|
const before = typeof req.body?.before === 'string' ? req.body.before : undefined;
|
||||||
|
const changed = db.markAllRead(new Date().toISOString(), before);
|
||||||
|
res.json({ ok: true, changed });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/prs/:id/read-all', (req, res) => {
|
||||||
|
const changed = db.markAllReadForPr(req.params.id, new Date().toISOString());
|
||||||
|
res.json({ ok: true, changed });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/sync', (_req, res) => {
|
||||||
|
void triggerSync();
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/events', (_req, res) => {
|
||||||
|
addClient(res, buildSyncPayload(db));
|
||||||
|
});
|
||||||
|
|
||||||
|
const webDist = join(rootDir, 'web', 'dist');
|
||||||
|
if (existsSync(webDist)) {
|
||||||
|
app.use(express.static(webDist));
|
||||||
|
app.get(/^\/(?!api\/).*/, (_req, res) => {
|
||||||
|
res.sendFile(join(webDist, 'index.html'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
30
server/sse.ts
Normal file
30
server/sse.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import type { Response } from 'express';
|
||||||
|
import type { SyncEventPayload } from '../shared/types.js';
|
||||||
|
|
||||||
|
const clients = new Set<Response>();
|
||||||
|
|
||||||
|
export function addClient(res: Response, initial: SyncEventPayload): void {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'content-type': 'text/event-stream',
|
||||||
|
'cache-control': 'no-cache',
|
||||||
|
connection: 'keep-alive',
|
||||||
|
'x-accel-buffering': 'no',
|
||||||
|
});
|
||||||
|
res.write('retry: 3000\n\n');
|
||||||
|
res.write(frame(initial));
|
||||||
|
clients.add(res);
|
||||||
|
res.on('close', () => clients.delete(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function broadcast(payload: SyncEventPayload): void {
|
||||||
|
const data = frame(payload);
|
||||||
|
for (const res of clients) res.write(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function frame(payload: SyncEventPayload): string {
|
||||||
|
return `event: sync\ndata: ${JSON.stringify(payload)}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
for (const res of clients) res.write(': ping\n\n');
|
||||||
|
}, 25_000).unref();
|
||||||
25
web/index.html
Normal file
25
web/index.html
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<title>PR Monitor</title>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='4' cy='3' r='2.2' fill='%232a78d6'/%3E%3Ccircle cx='4' cy='13' r='2.2' fill='%232a78d6'/%3E%3Ccircle cx='12' cy='8' r='2.2' fill='%230ca30c'/%3E%3Cpath d='M4 5v6M12 6V5a3 3 0 0 0-3-3' stroke='%23898781' stroke-width='1.6' fill='none'/%3E%3C/svg%3E"
|
||||||
|
/>
|
||||||
|
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f9f9f7" />
|
||||||
|
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0d0d0d" />
|
||||||
|
<script>
|
||||||
|
// Apply the stored theme before first paint to avoid a flash.
|
||||||
|
(function () {
|
||||||
|
var t = localStorage.getItem('theme');
|
||||||
|
if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t;
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
55
web/src/App.tsx
Normal file
55
web/src/App.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { fetchState } from './api';
|
||||||
|
import { useSSE } from './useSSE';
|
||||||
|
import Header from './components/Header';
|
||||||
|
import PrList from './components/PrList';
|
||||||
|
import Timeline from './components/Timeline';
|
||||||
|
|
||||||
|
type MobileView = 'prs' | 'timeline';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const sse = useSSE();
|
||||||
|
const stateQuery = useQuery({ queryKey: ['state'], queryFn: fetchState });
|
||||||
|
const [mobileView, setMobileView] = useState<MobileView>('prs');
|
||||||
|
|
||||||
|
const state = stateQuery.data;
|
||||||
|
const lastSyncAt = sse.lastSync?.lastSyncAt ?? state?.lastSyncAt ?? null;
|
||||||
|
const error = sse.lastSync ? sse.lastSync.error : (state?.lastSyncError ?? null);
|
||||||
|
const unreadTotal = sse.lastSync?.unreadCount ?? state?.unreadTotal ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<Header lastSyncAt={lastSyncAt} error={error} connected={sse.connected} />
|
||||||
|
<div className="columns">
|
||||||
|
<div className={`pane pane-prs${mobileView === 'prs' ? ' active' : ''}`}>
|
||||||
|
{state ? (
|
||||||
|
<PrList state={state} />
|
||||||
|
) : (
|
||||||
|
<div className="empty-note">
|
||||||
|
{stateQuery.isError ? 'Failed to load — is the server running?' : 'Loading…'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={`pane pane-timeline${mobileView === 'timeline' ? ' active' : ''}`}>
|
||||||
|
<Timeline />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav className="mobile-tabs">
|
||||||
|
<button
|
||||||
|
className={mobileView === 'prs' ? 'active' : ''}
|
||||||
|
onClick={() => setMobileView('prs')}
|
||||||
|
>
|
||||||
|
Pull Requests
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={mobileView === 'timeline' ? 'active' : ''}
|
||||||
|
onClick={() => setMobileView('timeline')}
|
||||||
|
>
|
||||||
|
{unreadTotal > 0 && <span className="tab-badge">{unreadTotal}</span>}
|
||||||
|
Activity
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
web/src/api.ts
Normal file
33
web/src/api.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import type { StatePayload, TimelinePage } from '../../shared/types';
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(path, init);
|
||||||
|
if (!res.ok) throw new Error(`${path}: ${res.status}`);
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchState = (): Promise<StatePayload> => request('/api/state');
|
||||||
|
|
||||||
|
export const fetchTimeline = (opts: {
|
||||||
|
cursor?: string;
|
||||||
|
unreadOnly: boolean;
|
||||||
|
}): Promise<TimelinePage> => {
|
||||||
|
const params = new URLSearchParams({ limit: '50' });
|
||||||
|
if (opts.cursor) params.set('before', opts.cursor);
|
||||||
|
if (opts.unreadOnly) params.set('unread', '1');
|
||||||
|
return request(`/api/timeline?${params}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const markRead = (id: string, read: boolean): Promise<unknown> =>
|
||||||
|
request(`/api/timeline/${encodeURIComponent(id)}/${read ? 'read' : 'unread'}`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const markAllRead = (): Promise<unknown> =>
|
||||||
|
request('/api/timeline/read-all', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: '{}',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const triggerSync = (): Promise<unknown> => request('/api/sync', { method: 'POST' });
|
||||||
104
web/src/components/CategoryChips.tsx
Normal file
104
web/src/components/CategoryChips.tsx
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import type { CategoryStatus, CategoryState } from '../../../shared/types';
|
||||||
|
|
||||||
|
const ICONS: Record<CategoryState, string> = {
|
||||||
|
pass: '✓',
|
||||||
|
fail: '✗',
|
||||||
|
running: '◐',
|
||||||
|
pending: '○',
|
||||||
|
skipped: '−',
|
||||||
|
none: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const LABELS: Record<string, string> = {
|
||||||
|
lint: 'lint',
|
||||||
|
specs: 'specs',
|
||||||
|
e2e: 'e2e',
|
||||||
|
other: 'other',
|
||||||
|
};
|
||||||
|
|
||||||
|
function Chip({ status }: { status: CategoryStatus }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [alignRight, setAlignRight] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const updateAlignment = () => {
|
||||||
|
const rect = ref.current?.getBoundingClientRect();
|
||||||
|
if (rect) setAlignRight(rect.left + 300 > window.innerWidth);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const close = (e: PointerEvent) => {
|
||||||
|
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', close);
|
||||||
|
return () => document.removeEventListener('pointerdown', close);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const showCount = status.total > 0 && status.passed < status.total;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`chip-wrap${open ? ' open' : ''}${alignRight ? ' align-right' : ''}`}
|
||||||
|
ref={ref}
|
||||||
|
onMouseEnter={updateAlignment}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className={`chip chip-${status.state}`}
|
||||||
|
onClick={() => {
|
||||||
|
updateAlignment();
|
||||||
|
setOpen((o) => !o);
|
||||||
|
}}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={`${LABELS[status.category]}: ${status.state}, ${status.passed} of ${status.total} passed`}
|
||||||
|
>
|
||||||
|
<span className="chip-icon">{ICONS[status.state]}</span>
|
||||||
|
{LABELS[status.category]}
|
||||||
|
{showCount && (
|
||||||
|
<span className="count">
|
||||||
|
{status.passed}/{status.total}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="check-dropdown" role="list">
|
||||||
|
{status.checks.map((check) =>
|
||||||
|
check.url ? (
|
||||||
|
<a
|
||||||
|
key={check.name}
|
||||||
|
className="check-row"
|
||||||
|
href={check.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
role="listitem"
|
||||||
|
>
|
||||||
|
<span className={`check-icon ${check.state}`}>{ICONS[check.state]}</span>
|
||||||
|
<span className="check-name" title={check.name}>
|
||||||
|
{check.name}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div key={check.name} className="check-row" role="listitem">
|
||||||
|
<span className={`check-icon ${check.state}`}>{ICONS[check.state]}</span>
|
||||||
|
<span className="check-name" title={check.name}>
|
||||||
|
{check.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CategoryChips({ categories }: { categories: CategoryStatus[] }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{categories
|
||||||
|
.filter((c) => c.checks.length > 0)
|
||||||
|
.map((c) => (
|
||||||
|
<Chip key={c.category} status={c} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
web/src/components/Header.tsx
Normal file
57
web/src/components/Header.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getTheme, nextTheme, setTheme, type ThemeChoice } from '../theme';
|
||||||
|
import { relativeTime } from '../format';
|
||||||
|
import { triggerSync } from '../api';
|
||||||
|
|
||||||
|
const THEME_ICON: Record<ThemeChoice, string> = { light: '☀', dark: '☾', system: '◑' };
|
||||||
|
|
||||||
|
export default function Header({
|
||||||
|
lastSyncAt,
|
||||||
|
error,
|
||||||
|
connected,
|
||||||
|
}: {
|
||||||
|
lastSyncAt: string | null;
|
||||||
|
error: string | null;
|
||||||
|
connected: boolean;
|
||||||
|
}) {
|
||||||
|
const [theme, setThemeState] = useState<ThemeChoice>(getTheme);
|
||||||
|
// Re-render every 30s so relative times stay fresh.
|
||||||
|
const [, tick] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setInterval(() => tick((n) => n + 1), 30_000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stale = lastSyncAt !== null && Date.now() - Date.parse(lastSyncAt) > 3 * 60_000;
|
||||||
|
const dotClass = error ? 'error' : stale || !connected ? 'stale' : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="header">
|
||||||
|
<h1>PR Monitor</h1>
|
||||||
|
<div className="spacer" />
|
||||||
|
<button
|
||||||
|
className="sync-status"
|
||||||
|
onClick={() => void triggerSync()}
|
||||||
|
title="Trigger sync now"
|
||||||
|
>
|
||||||
|
<span className={`sync-dot ${dotClass}`} />
|
||||||
|
{connected ? `synced ${relativeTime(lastSyncAt)}` : 'reconnecting…'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="icon-btn"
|
||||||
|
onClick={() => {
|
||||||
|
const next = nextTheme(theme);
|
||||||
|
setTheme(next);
|
||||||
|
setThemeState(next);
|
||||||
|
}}
|
||||||
|
title={`Theme: ${theme}`}
|
||||||
|
aria-label={`Theme: ${theme}. Click to change.`}
|
||||||
|
>
|
||||||
|
{THEME_ICON[theme]}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
{error && <div className="error-banner">Sync error: {error}</div>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
web/src/components/PrCard.tsx
Normal file
30
web/src/components/PrCard.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import type { PrSnapshot } from '../../../shared/types';
|
||||||
|
import CategoryChips from './CategoryChips';
|
||||||
|
import ReviewBadge from './ReviewBadge';
|
||||||
|
import { relativeTime } from '../format';
|
||||||
|
|
||||||
|
export default function PrCard({ pr, unread }: { pr: PrSnapshot; unread: number }) {
|
||||||
|
return (
|
||||||
|
<div className="pr-card">
|
||||||
|
<div className="title-row">
|
||||||
|
<span className="pr-number">#{pr.number}</span>
|
||||||
|
<a className="pr-title" href={pr.url} target="_blank" rel="noreferrer">
|
||||||
|
{pr.title}
|
||||||
|
</a>
|
||||||
|
{unread > 0 && <span className="unread-pill">{unread}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="meta-row">
|
||||||
|
{pr.isDraft && <span className="draft-tag">draft</span>}
|
||||||
|
<span>{pr.author}</span>
|
||||||
|
<span className="branch" title={pr.branch}>
|
||||||
|
{pr.branch}
|
||||||
|
</span>
|
||||||
|
<span>{relativeTime(pr.updatedAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="chips-row">
|
||||||
|
<ReviewBadge pr={pr} />
|
||||||
|
<CategoryChips categories={pr.categories} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
web/src/components/PrList.tsx
Normal file
58
web/src/components/PrList.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { StatePayload } from '../../../shared/types';
|
||||||
|
import PrCard from './PrCard';
|
||||||
|
import { shortRepo } from '../format';
|
||||||
|
|
||||||
|
type Filter = 'all' | 'unread' | string;
|
||||||
|
|
||||||
|
export default function PrList({ state }: { state: StatePayload }) {
|
||||||
|
const [filter, setFilter] = useState<Filter>('all');
|
||||||
|
|
||||||
|
const repos = [...new Set(state.prs.map((p) => p.repo))];
|
||||||
|
const filtered = state.prs.filter((pr) => {
|
||||||
|
if (filter === 'all') return true;
|
||||||
|
if (filter === 'unread') return (state.unreadCountsByPr[pr.id] ?? 0) > 0;
|
||||||
|
return pr.repo === filter;
|
||||||
|
});
|
||||||
|
|
||||||
|
const byRepo = new Map<string, typeof filtered>();
|
||||||
|
for (const pr of filtered) {
|
||||||
|
if (!byRepo.has(pr.repo)) byRepo.set(pr.repo, []);
|
||||||
|
byRepo.get(pr.repo)!.push(pr);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="filter-tabs">
|
||||||
|
<button className={filter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>
|
||||||
|
All ({state.prs.length})
|
||||||
|
</button>
|
||||||
|
<button className={filter === 'unread' ? 'active' : ''} onClick={() => setFilter('unread')}>
|
||||||
|
Unread{state.unreadTotal > 0 ? ` (${state.unreadTotal})` : ''}
|
||||||
|
</button>
|
||||||
|
{repos.map((repo) => (
|
||||||
|
<button
|
||||||
|
key={repo}
|
||||||
|
className={filter === repo ? 'active' : ''}
|
||||||
|
onClick={() => setFilter(repo)}
|
||||||
|
>
|
||||||
|
{shortRepo(repo)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="pr-list">
|
||||||
|
{filtered.length === 0 && <div className="empty-note">No PRs match.</div>}
|
||||||
|
{[...byRepo.entries()].map(([repo, prs]) => (
|
||||||
|
<div key={repo}>
|
||||||
|
<div className="repo-heading">{repo}</div>
|
||||||
|
{prs.map((pr) => (
|
||||||
|
<div key={pr.id} style={{ marginBottom: 8 }}>
|
||||||
|
<PrCard pr={pr} unread={state.unreadCountsByPr[pr.id] ?? 0} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
web/src/components/ReviewBadge.tsx
Normal file
36
web/src/components/ReviewBadge.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import type { PrSnapshot } from '../../../shared/types';
|
||||||
|
|
||||||
|
const STATE_LABEL = {
|
||||||
|
no_review: { icon: '◌', text: 'no review', cls: '' },
|
||||||
|
outstanding: { icon: '●', text: 'review outstanding', cls: 'outstanding' },
|
||||||
|
addressed: { icon: '✓', text: 'review addressed', cls: 'addressed' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export default function ReviewBadge({ pr }: { pr: PrSnapshot }) {
|
||||||
|
const s = STATE_LABEL[pr.reviewState];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className={`review-badge ${s.cls}`}>
|
||||||
|
<span aria-hidden>{s.icon}</span>
|
||||||
|
{s.text}
|
||||||
|
{pr.reviewState === 'outstanding' && pr.unresolvedThreads > 0 && (
|
||||||
|
<span>
|
||||||
|
· {pr.unresolvedThreads} thread{pr.unresolvedThreads > 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{pr.verdicts.map((v) => (
|
||||||
|
<a
|
||||||
|
key={v.reviewer}
|
||||||
|
className={`verdict ${v.state === 'APPROVED' ? 'approved' : 'changes'}`}
|
||||||
|
href={v.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
title={`${v.reviewer}: ${v.state === 'APPROVED' ? 'approved' : 'requested changes'}`}
|
||||||
|
>
|
||||||
|
{v.state === 'APPROVED' ? '✓' : '±'} {v.reviewer}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
169
web/src/components/Timeline.tsx
Normal file
169
web/src/components/Timeline.tsx
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import type { TimelineEvent } from '../../../shared/types';
|
||||||
|
import { fetchTimeline, markAllRead, markRead } from '../api';
|
||||||
|
import { relativeTime, shortRepo } from '../format';
|
||||||
|
|
||||||
|
const GROUP_WINDOW_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
interface Group {
|
||||||
|
key: string;
|
||||||
|
actor: string;
|
||||||
|
repo: string;
|
||||||
|
prNumber: number;
|
||||||
|
prTitle: string;
|
||||||
|
events: TimelineEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupEvents(events: TimelineEvent[]): Group[] {
|
||||||
|
const groups: Group[] = [];
|
||||||
|
for (const e of events) {
|
||||||
|
const last = groups[groups.length - 1];
|
||||||
|
const lastEvent = last?.events[last.events.length - 1];
|
||||||
|
if (
|
||||||
|
last &&
|
||||||
|
lastEvent &&
|
||||||
|
last.actor === e.actor &&
|
||||||
|
last.repo === e.repo &&
|
||||||
|
last.prNumber === e.prNumber &&
|
||||||
|
Date.parse(lastEvent.createdAt) - Date.parse(e.createdAt) < GROUP_WINDOW_MS
|
||||||
|
) {
|
||||||
|
last.events.push(e);
|
||||||
|
} else {
|
||||||
|
groups.push({
|
||||||
|
key: e.id,
|
||||||
|
actor: e.actor,
|
||||||
|
repo: e.repo,
|
||||||
|
prNumber: e.prNumber,
|
||||||
|
prTitle: e.prTitle,
|
||||||
|
events: [e],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function KindLabel({ event }: { event: TimelineEvent }) {
|
||||||
|
if (event.kind === 'review') {
|
||||||
|
const cls =
|
||||||
|
event.reviewState === 'APPROVED'
|
||||||
|
? 'approved'
|
||||||
|
: event.reviewState === 'CHANGES_REQUESTED'
|
||||||
|
? 'changes'
|
||||||
|
: '';
|
||||||
|
const label =
|
||||||
|
event.reviewState === 'APPROVED'
|
||||||
|
? 'approved'
|
||||||
|
: event.reviewState === 'CHANGES_REQUESTED'
|
||||||
|
? 'requested changes'
|
||||||
|
: 'reviewed';
|
||||||
|
return <span className={`tl-review-state ${cls}`}>{label}</span>;
|
||||||
|
}
|
||||||
|
if (event.kind === 'review_comment') {
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
commented{event.path ? ' on ' : ''}
|
||||||
|
{event.path && <span className="path">{event.path.split('/').pop()}</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span>commented</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Timeline() {
|
||||||
|
const [unreadOnly, setUnreadOnly] = useState(false);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const query = useInfiniteQuery({
|
||||||
|
queryKey: ['timeline', unreadOnly],
|
||||||
|
queryFn: ({ pageParam }) => fetchTimeline({ cursor: pageParam, unreadOnly }),
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
getNextPageParam: (page) => page.nextCursor ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidate = () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['timeline'] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['state'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const readMutation = useMutation({
|
||||||
|
mutationFn: ({ id, read }: { id: string; read: boolean }) => markRead(id, read),
|
||||||
|
onSettled: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const readAllMutation = useMutation({ mutationFn: markAllRead, onSettled: invalidate });
|
||||||
|
|
||||||
|
const events = query.data?.pages.flatMap((p) => p.events) ?? [];
|
||||||
|
const groups = groupEvents(events);
|
||||||
|
|
||||||
|
const openEvent = (e: TimelineEvent) => {
|
||||||
|
if (!e.read) readMutation.mutate({ id: e.id, read: true });
|
||||||
|
window.open(e.url, '_blank', 'noopener');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="timeline-head">
|
||||||
|
<h2>Activity</h2>
|
||||||
|
<button className={unreadOnly ? 'active' : ''} onClick={() => setUnreadOnly((v) => !v)}>
|
||||||
|
Unread only
|
||||||
|
</button>
|
||||||
|
<button onClick={() => readAllMutation.mutate()}>Mark all read</button>
|
||||||
|
</div>
|
||||||
|
<div className="timeline-list">
|
||||||
|
{query.isLoading && <div className="empty-note">Loading…</div>}
|
||||||
|
{!query.isLoading && groups.length === 0 && (
|
||||||
|
<div className="empty-note">{unreadOnly ? 'Nothing unread.' : 'No activity yet.'}</div>
|
||||||
|
)}
|
||||||
|
{groups.map((group) => (
|
||||||
|
<div className="tl-group" key={group.key}>
|
||||||
|
<div className="tl-group-head">
|
||||||
|
<span className="actor">{group.actor}</span>
|
||||||
|
<a
|
||||||
|
href={`https://github.com/${group.repo}/pull/${group.prNumber}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
title={group.prTitle}
|
||||||
|
>
|
||||||
|
{shortRepo(group.repo)}#{group.prNumber} · {group.prTitle}
|
||||||
|
</a>
|
||||||
|
<span>{relativeTime(group.events[0]!.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
{group.events.map((e) => (
|
||||||
|
<div
|
||||||
|
key={e.id}
|
||||||
|
className={`tl-item${e.read ? '' : ' unread'}`}
|
||||||
|
onClick={() => openEvent(e)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="dot"
|
||||||
|
aria-label={e.read ? 'mark unread' : 'mark read'}
|
||||||
|
title={e.read ? 'Mark unread' : 'Mark read'}
|
||||||
|
onClick={(ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
readMutation.mutate({ id: e.id, read: !e.read });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="tl-kind">
|
||||||
|
<KindLabel event={e} />
|
||||||
|
</div>
|
||||||
|
{e.bodyExcerpt && <div className="body">{e.bodyExcerpt}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{query.hasNextPage && (
|
||||||
|
<button
|
||||||
|
className="load-more"
|
||||||
|
onClick={() => query.fetchNextPage()}
|
||||||
|
disabled={query.isFetchingNextPage}
|
||||||
|
>
|
||||||
|
{query.isFetchingNextPage ? 'Loading…' : 'Load older'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
web/src/format.ts
Normal file
16
web/src/format.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
export function relativeTime(iso: string | null): string {
|
||||||
|
if (!iso) return 'never';
|
||||||
|
const seconds = Math.round((Date.now() - Date.parse(iso)) / 1000);
|
||||||
|
if (seconds < 45) return 'just now';
|
||||||
|
if (seconds < 90) return '1 min ago';
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes} min ago`;
|
||||||
|
const hours = Math.round(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.round(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shortRepo(repo: string): string {
|
||||||
|
return repo.split('/')[1] ?? repo;
|
||||||
|
}
|
||||||
20
web/src/main.tsx
Normal file
20
web/src/main.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import App from './App';
|
||||||
|
import './styles/tokens.css';
|
||||||
|
import './styles/app.css';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { staleTime: 30_000, refetchOnWindowFocus: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<App />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
629
web/src/styles/app.css
Normal file
629
web/src/styles/app.css
Normal file
@ -0,0 +1,629 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
|
background: var(--page);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- header ---------- */
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid var(--hairline);
|
||||||
|
background: var(--surface);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header .spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--ink-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--status-pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-dot.stale {
|
||||||
|
background: var(--status-running);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-dot.error {
|
||||||
|
background: var(--status-fail);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
background: var(--chip-fail-bg);
|
||||||
|
color: var(--status-fail-text);
|
||||||
|
padding: 6px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-bottom: 1px solid var(--hairline);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
border-color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- layout ---------- */
|
||||||
|
|
||||||
|
.columns {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane {
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-prs {
|
||||||
|
flex: 3;
|
||||||
|
border-right: 1px solid var(--hairline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-timeline {
|
||||||
|
flex: 2;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-tabs {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 899px) {
|
||||||
|
.columns .pane {
|
||||||
|
display: none;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.columns .pane.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-prs {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-tabs {
|
||||||
|
display: flex;
|
||||||
|
border-top: 1px solid var(--hairline);
|
||||||
|
background: var(--surface);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-tabs button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-tabs button.active {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-badge {
|
||||||
|
position: absolute;
|
||||||
|
transform: translate(14px, -2px);
|
||||||
|
background: var(--unread);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 8px;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
line-height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- filter tabs ---------- */
|
||||||
|
|
||||||
|
.filter-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 16px 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tabs button {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--ink-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tabs button.active {
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--page);
|
||||||
|
border-color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- PR cards ---------- */
|
||||||
|
|
||||||
|
.pr-list {
|
||||||
|
padding: 8px 16px 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-heading {
|
||||||
|
margin: 10px 0 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 650;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-card .title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-card .pr-number {
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-card .pr-title {
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-card .pr-title:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-card .meta-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draft-tag {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0 5px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ink-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 220px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unread-pill {
|
||||||
|
background: var(--unread);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 9px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0 6px;
|
||||||
|
line-height: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- category chips + dropdown ---------- */
|
||||||
|
|
||||||
|
.chip-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 13px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip .count {
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-pass {
|
||||||
|
background: var(--chip-pass-bg);
|
||||||
|
color: var(--status-pass-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-fail {
|
||||||
|
background: var(--chip-fail-bg);
|
||||||
|
color: var(--status-fail-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-running {
|
||||||
|
background: var(--chip-running-bg);
|
||||||
|
color: var(--status-running-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-running .chip-icon {
|
||||||
|
animation: pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-pending {
|
||||||
|
background: var(--chip-pending-bg);
|
||||||
|
color: var(--ink-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-skipped {
|
||||||
|
background: var(--chip-skipped-bg);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
50% {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-dropdown {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
z-index: 30;
|
||||||
|
min-width: 260px;
|
||||||
|
max-width: min(340px, 90vw);
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-wrap.align-right .check-dropdown {
|
||||||
|
left: auto;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-wrap.open .check-dropdown {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.chip-wrap:hover .check-dropdown {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.check-row:hover {
|
||||||
|
background: var(--chip-pending-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-row .check-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-icon {
|
||||||
|
font-size: 11px;
|
||||||
|
width: 14px;
|
||||||
|
text-align: center;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-icon.pass {
|
||||||
|
color: var(--status-pass-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-icon.fail {
|
||||||
|
color: var(--status-fail-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-icon.running {
|
||||||
|
color: var(--status-running-text);
|
||||||
|
animation: pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-icon.pending,
|
||||||
|
.check-icon.skipped {
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- review badge ---------- */
|
||||||
|
|
||||||
|
.review-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 13px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--ink-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-badge.outstanding {
|
||||||
|
border-color: var(--status-running);
|
||||||
|
color: var(--status-running-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-badge.addressed {
|
||||||
|
border-color: var(--status-pass);
|
||||||
|
color: var(--status-pass-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verdict {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verdict.approved {
|
||||||
|
background: var(--chip-pass-bg);
|
||||||
|
color: var(--status-pass-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verdict.changes {
|
||||||
|
background: var(--chip-fail-bg);
|
||||||
|
color: var(--status-fail-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- timeline ---------- */
|
||||||
|
|
||||||
|
.timeline-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px 6px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-head h2 {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 650;
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-head button {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-head button.active {
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--page);
|
||||||
|
border-color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-list {
|
||||||
|
padding: 0 8px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-group {
|
||||||
|
margin: 10px 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-group-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-secondary);
|
||||||
|
padding: 0 6px 2px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-group-head .actor {
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-group-head a {
|
||||||
|
color: var(--ink-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-group-head a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-item:hover {
|
||||||
|
background: var(--chip-pending-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-item .dot {
|
||||||
|
flex: none;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-top: 6px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-item.unread .dot {
|
||||||
|
background: var(--unread);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-item .body {
|
||||||
|
color: var(--ink-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-item.unread .body {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-kind {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-kind .path {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-review-state {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-review-state.approved {
|
||||||
|
color: var(--status-pass-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-review-state.changes {
|
||||||
|
color: var(--status-fail-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more {
|
||||||
|
display: block;
|
||||||
|
margin: 12px auto;
|
||||||
|
padding: 5px 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
color: var(--ink-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-note {
|
||||||
|
color: var(--ink-muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
87
web/src/styles/tokens.css
Normal file
87
web/src/styles/tokens.css
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--page: #f9f9f7;
|
||||||
|
--surface: #fcfcfb;
|
||||||
|
--surface-raised: #ffffff;
|
||||||
|
--ink: #0b0b0b;
|
||||||
|
--ink-secondary: #52514e;
|
||||||
|
--ink-muted: #898781;
|
||||||
|
--hairline: #e1e0d9;
|
||||||
|
--border: rgba(11, 11, 11, 0.1);
|
||||||
|
--accent: #2a78d6;
|
||||||
|
|
||||||
|
/* Status palette (mode-invariant hues; always paired with icon + label). */
|
||||||
|
--status-pass: #0ca30c;
|
||||||
|
--status-pass-text: #006300;
|
||||||
|
--status-fail: #d03b3b;
|
||||||
|
--status-fail-text: #b02f2f;
|
||||||
|
--status-running: #fab219;
|
||||||
|
--status-running-text: #8a5f00;
|
||||||
|
--status-pending: #898781;
|
||||||
|
--status-skipped: #b8b6ae;
|
||||||
|
|
||||||
|
--chip-pass-bg: rgba(12, 163, 12, 0.1);
|
||||||
|
--chip-fail-bg: rgba(208, 59, 59, 0.12);
|
||||||
|
--chip-running-bg: rgba(250, 178, 25, 0.16);
|
||||||
|
--chip-pending-bg: rgba(137, 135, 129, 0.12);
|
||||||
|
--chip-skipped-bg: rgba(137, 135, 129, 0.07);
|
||||||
|
|
||||||
|
--unread: #2a78d6;
|
||||||
|
--shadow: 0 4px 16px rgba(11, 11, 11, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:where(:not([data-theme='light'])) {
|
||||||
|
color-scheme: dark;
|
||||||
|
--page: #0d0d0d;
|
||||||
|
--surface: #1a1a19;
|
||||||
|
--surface-raised: #242423;
|
||||||
|
--ink: #ffffff;
|
||||||
|
--ink-secondary: #c3c2b7;
|
||||||
|
--ink-muted: #898781;
|
||||||
|
--hairline: #2c2c2a;
|
||||||
|
--border: rgba(255, 255, 255, 0.1);
|
||||||
|
--accent: #3987e5;
|
||||||
|
|
||||||
|
--status-pass-text: #4cc24c;
|
||||||
|
--status-fail-text: #e66767;
|
||||||
|
--status-running-text: #fab219;
|
||||||
|
--status-skipped: #55544f;
|
||||||
|
|
||||||
|
--chip-pass-bg: rgba(12, 163, 12, 0.16);
|
||||||
|
--chip-fail-bg: rgba(208, 59, 59, 0.2);
|
||||||
|
--chip-running-bg: rgba(250, 178, 25, 0.16);
|
||||||
|
--chip-pending-bg: rgba(137, 135, 129, 0.18);
|
||||||
|
--chip-skipped-bg: rgba(137, 135, 129, 0.1);
|
||||||
|
|
||||||
|
--unread: #3987e5;
|
||||||
|
--shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--page: #0d0d0d;
|
||||||
|
--surface: #1a1a19;
|
||||||
|
--surface-raised: #242423;
|
||||||
|
--ink: #ffffff;
|
||||||
|
--ink-secondary: #c3c2b7;
|
||||||
|
--ink-muted: #898781;
|
||||||
|
--hairline: #2c2c2a;
|
||||||
|
--border: rgba(255, 255, 255, 0.1);
|
||||||
|
--accent: #3987e5;
|
||||||
|
|
||||||
|
--status-pass-text: #4cc24c;
|
||||||
|
--status-fail-text: #e66767;
|
||||||
|
--status-running-text: #fab219;
|
||||||
|
--status-skipped: #55544f;
|
||||||
|
|
||||||
|
--chip-pass-bg: rgba(12, 163, 12, 0.16);
|
||||||
|
--chip-fail-bg: rgba(208, 59, 59, 0.2);
|
||||||
|
--chip-running-bg: rgba(250, 178, 25, 0.16);
|
||||||
|
--chip-pending-bg: rgba(137, 135, 129, 0.18);
|
||||||
|
--chip-skipped-bg: rgba(137, 135, 129, 0.1);
|
||||||
|
|
||||||
|
--unread: #3987e5;
|
||||||
|
--shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
20
web/src/theme.ts
Normal file
20
web/src/theme.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
export type ThemeChoice = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
|
export function getTheme(): ThemeChoice {
|
||||||
|
const stored = localStorage.getItem('theme');
|
||||||
|
return stored === 'light' || stored === 'dark' ? stored : 'system';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTheme(choice: ThemeChoice): void {
|
||||||
|
if (choice === 'system') {
|
||||||
|
localStorage.removeItem('theme');
|
||||||
|
delete document.documentElement.dataset.theme;
|
||||||
|
} else {
|
||||||
|
localStorage.setItem('theme', choice);
|
||||||
|
document.documentElement.dataset.theme = choice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextTheme(current: ThemeChoice): ThemeChoice {
|
||||||
|
return current === 'system' ? 'light' : current === 'light' ? 'dark' : 'system';
|
||||||
|
}
|
||||||
30
web/src/useSSE.ts
Normal file
30
web/src/useSSE.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type { SyncEventPayload } from '../../shared/types';
|
||||||
|
|
||||||
|
export interface ConnectionState {
|
||||||
|
connected: boolean;
|
||||||
|
lastSync: SyncEventPayload | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSSE(): ConnectionState {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [state, setState] = useState<ConnectionState>({ connected: false, lastSync: null });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const source = new EventSource('/api/events');
|
||||||
|
source.onopen = () => setState((s) => ({ ...s, connected: true }));
|
||||||
|
source.onerror = () => setState((s) => ({ ...s, connected: false }));
|
||||||
|
source.addEventListener('sync', (e) => {
|
||||||
|
const payload = JSON.parse((e as MessageEvent).data) as SyncEventPayload;
|
||||||
|
setState({ connected: true, lastSync: payload });
|
||||||
|
if (payload.changedPrIds.length > 0 || payload.newEventCount > 0) {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['state'] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['timeline'] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => source.close();
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
14
web/tsconfig.json
Normal file
14
web/tsconfig.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
||||||
|
},
|
||||||
|
"include": ["src", "../shared"]
|
||||||
|
}
|
||||||
12
web/vite.config.ts
Normal file
12
web/vite.config.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': { target: 'http://localhost:4000' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user