diff --git a/package.json b/package.json index f8deffa..b0f12ed 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "scripts": { "test": "vitest run", "test:watch": "vitest", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "build": "node --experimental-strip-types src/cli.ts" }, "engines": { "node": ">=22" diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..5da36d1 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,39 @@ +import { copyFileSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createGiteaClient } from './gitea.ts'; +import { readBoard } from './reader.ts'; +import { renderBoard } from './render.ts'; +import { defaultConfigPath, loadConfig } from './config.ts'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..'); +const STATIC_SRC = join(ROOT, 'static'); +const DIST_DIR = process.env['BOARD_DIST_DIR'] ?? join(ROOT, 'dist'); + +async function main(): Promise { + const configPath = process.argv[2] ?? defaultConfigPath(); + const cfg = loadConfig(configPath); + + const client = createGiteaClient({ baseUrl: cfg.baseUrl, token: cfg.token }); + const records = await readBoard(client, cfg.repos); + + const html = renderBoard(records, { generatedAt: new Date() }); + + mkdirSync(DIST_DIR, { recursive: true }); + const indexPath = join(DIST_DIR, 'index.html'); + writeFileSync(indexPath, html, 'utf8'); + + const distStatic = join(DIST_DIR, 'static'); + mkdirSync(distStatic, { recursive: true }); + for (const entry of readdirSync(STATIC_SRC)) { + copyFileSync(join(STATIC_SRC, entry), join(distStatic, entry)); + } + + console.log(`wrote ${indexPath} (${records.length} records from ${cfg.repos.length} repos)`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/static/board.css b/static/board.css new file mode 100644 index 0000000..6c7e68e --- /dev/null +++ b/static/board.css @@ -0,0 +1,257 @@ +:root { + --bg: #ffffff; + --fg: #111111; + --muted: #666666; + --border: #e2e2e2; + --card-bg: #fafafa; + --accent: #1a1a1a; + --badge-bg: #efefef; + --col-gap: 1rem; + --card-gap: 0.5rem; + --radius: 6px; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0e0e10; + --fg: #ececec; + --muted: #888888; + --border: #2a2a2d; + --card-bg: #18181b; + --accent: #f5f5f5; + --badge-bg: #2a2a2d; + } +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--fg); + font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif; +} + +header { + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border); + display: flex; + align-items: baseline; + gap: 1.5rem; + flex-wrap: wrap; +} + +header h1 { + font-size: 1.1rem; + font-weight: 600; + margin: 0; + letter-spacing: -0.01em; +} + +.controls { + display: flex; + gap: 1rem; + align-items: center; + flex-wrap: wrap; + font-size: 0.85rem; + color: var(--muted); +} + +.controls input[type="search"] { + font: inherit; + padding: 0.3rem 0.6rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg); + color: var(--fg); + min-width: 18rem; +} + +.controls label { + display: inline-flex; + align-items: center; + gap: 0.3rem; + cursor: pointer; + user-select: none; +} + +.refresh-info { + margin-left: auto; + font-variant-numeric: tabular-nums; +} + +main.board { + display: grid; + grid-template-columns: repeat(5, minmax(14rem, 1fr)); + gap: var(--col-gap); + padding: 1.5rem; +} + +@media (max-width: 1100px) { + main.board { grid-template-columns: repeat(2, 1fr); } +} +@media (max-width: 600px) { + main.board { grid-template-columns: 1fr; } +} + +.col { + display: flex; + flex-direction: column; + gap: var(--card-gap); + min-height: 8rem; +} + +.col h2 { + font-size: 0.85rem; + font-weight: 600; + margin: 0 0 0.4rem; + text-transform: lowercase; + letter-spacing: 0.02em; + display: flex; + align-items: center; + gap: 0.4rem; +} + +.col h2 .count { + color: var(--muted); + font-weight: 400; + font-size: 0.75rem; + font-variant-numeric: tabular-nums; +} + +ul.cards { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--card-gap); +} + +.card { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.6rem 0.75rem; + cursor: pointer; + transition: border-color 0.12s; +} + +.card:hover { + border-color: var(--muted); +} + +.card-title { + font-size: 0.9rem; + line-height: 1.3; + margin-bottom: 0.4rem; + word-break: break-word; +} + +.card-meta { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.72rem; + color: var(--muted); + flex-wrap: wrap; +} + +.badge { + display: inline-block; + padding: 0.1rem 0.4rem; + background: var(--badge-bg); + border-radius: 999px; + font-variant-numeric: tabular-nums; +} + +.age { + margin-left: auto; + font-variant-numeric: tabular-nums; +} + +.card[data-archived="true"] { + opacity: 0.45; + display: none; +} + +body.show-archived .card[data-archived="true"] { + display: block; +} + +.card[data-hidden="true"] { + display: none; +} + +aside.drawer { + position: fixed; + top: 0; + right: 0; + width: min(40rem, 100vw); + height: 100vh; + background: var(--bg); + border-left: 1px solid var(--border); + padding: 1.5rem; + overflow-y: auto; + box-shadow: -2px 0 12px rgba(0,0,0,0.05); + z-index: 10; +} + +aside.drawer[hidden] { + display: none; +} + +.drawer-close { + position: absolute; + top: 0.5rem; + right: 0.8rem; + background: transparent; + border: none; + font-size: 1.4rem; + color: var(--muted); + cursor: pointer; +} + +.drawer-content { + margin-top: 1.5rem; +} + +.drawer-content h1, +.drawer-content h2, +.drawer-content h3 { + margin-top: 1.2rem; +} + +.drawer-content code { + font: 0.85rem/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; + background: var(--badge-bg); + padding: 0.05rem 0.3rem; + border-radius: 3px; +} + +.drawer-content pre { + background: var(--badge-bg); + padding: 0.8rem; + border-radius: var(--radius); + overflow-x: auto; +} + +.drawer-content pre code { + background: transparent; + padding: 0; +} + +body.group-by-project main.board { + grid-template-columns: 1fr; +} + +body.group-by-project main.board .col { + display: none; +} + +body.group-by-project main.board::after { + content: "grouping by project: not implemented yet"; + color: var(--muted); + padding: 1rem; +} diff --git a/static/board.js b/static/board.js new file mode 100644 index 0000000..86196a4 --- /dev/null +++ b/static/board.js @@ -0,0 +1,93 @@ +import { marked } from 'https://esm.sh/marked@14'; + +const recordsEl = document.getElementById('records'); +const records = recordsEl ? JSON.parse(recordsEl.textContent || '[]') : []; + +const filterInput = document.querySelector('.filter'); +const toggleArchived = document.querySelector('.toggle-archived'); +const toggleGrouping = document.querySelector('.toggle-grouping'); +const drawer = document.querySelector('.drawer'); +const drawerContent = drawer?.querySelector('.drawer-content'); +const drawerClose = drawer?.querySelector('.drawer-close'); +const refreshInfo = document.querySelector('.refresh-info'); +const header = document.querySelector('header'); + +const generatedAt = header?.dataset.generated ? new Date(header.dataset.generated) : null; +const NEXT_TICK_MIN = 5; + +function relativeMin(ms) { + return Math.max(0, Math.floor(ms / 60000)); +} + +function updateRefreshInfo() { + if (!refreshInfo || !generatedAt) return; + const ageMin = relativeMin(Date.now() - generatedAt.getTime()); + const nextMin = Math.max(0, NEXT_TICK_MIN - ageMin); + refreshInfo.textContent = `refreshed ${ageMin}m ago · next in ${nextMin}m`; +} +updateRefreshInfo(); +setInterval(updateRefreshInfo, 30_000); + +function applyFilter(query) { + const q = query.trim().toLowerCase(); + document.querySelectorAll('.card').forEach((card) => { + const slug = card.dataset.slug || ''; + const project = card.querySelector('.badge.project')?.textContent || ''; + const title = card.querySelector('.card-title')?.textContent || ''; + const hay = `${slug} ${project} ${title}`.toLowerCase(); + card.dataset.hidden = q === '' || hay.includes(q) ? 'false' : 'true'; + }); +} + +filterInput?.addEventListener('input', (e) => { + applyFilter(e.target.value); +}); + +toggleArchived?.addEventListener('change', (e) => { + document.body.classList.toggle('show-archived', e.target.checked); +}); + +toggleGrouping?.addEventListener('change', (e) => { + document.body.classList.toggle('group-by-project', e.target.checked); +}); + +async function openDrawer(card) { + if (!drawer || !drawerContent) return; + const rawUrl = card.dataset.rawUrl; + const slug = card.dataset.slug; + if (!rawUrl) return; + drawerContent.innerHTML = `

${slug}

loading…

`; + drawer.hidden = false; + try { + const res = await fetch(rawUrl, { credentials: 'omit' }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const md = await res.text(); + drawerContent.innerHTML = `

${escapeHtml(slug || '')}

` + marked.parse(md); + } catch (err) { + drawerContent.innerHTML = `

${escapeHtml(slug || '')}

Failed to load: ${escapeHtml(String(err))}

`; + } +} + +document.addEventListener('click', (e) => { + const card = e.target.closest?.('.card'); + if (card) openDrawer(card); +}); + +drawerClose?.addEventListener('click', () => { + if (drawer) drawer.hidden = true; +}); + +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && drawer) drawer.hidden = true; +}); + +function escapeHtml(s) { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +console.log(`board-viewer: ${records.length} records loaded`);