1/** Browse: public script gallery — ranked list + per-script proof detail. */2import './browse.css';3import { renderHighlighted } from '../highlight';4import { identicon } from '../identicon';5import type { BrowseWindow, ModelRecord, PublicScriptDetail, PublicScriptListing } from '@tribes/protocol';6import { social, type BrowseQuery, type BrowseSort } from '../api-social';7import { h, clear } from '../dom';8import type { AppCtx } from '../main';910export function renderBrowse(main: HTMLElement, ctx: AppCtx, id?: string): void {11 main.classList.add('wide');12 if (id !== undefined) renderDetail(main, ctx, id);13 else renderList(main, ctx);14}1516// --------------------------------------------------------------------------17// Shared bits18// --------------------------------------------------------------------------1920/** Two stable initials for a chip ("Stranglehold Raider" -> "SR"). */21function chipInitials(title: string): string {22 const words = title.split(/[^A-Za-z0-9]+/).filter((w) => w.length > 0);23 if (words.length === 0) return '??';24 if (words.length === 1) return words[0]!.slice(0, 2).toUpperCase();25 return (words[0]![0]! + words[1]![0]!).toUpperCase();26}2728function ordinal(n: number): string {29 const suffix = n === 1 ? 'st' : n === 2 ? 'nd' : n === 3 ? 'rd' : 'th';30 return `${n}${suffix}`;31}3233function fmtDate(iso: string): string {34 const d = new Date(iso);35 return d.toLocaleDateString(undefined, { year: '2-digit', month: 'short', day: 'numeric' });36}3738function errText(err: unknown): string {39 return err instanceof Error ? err.message : String(err);40}4142function modelChip(m: ModelRecord | null): HTMLElement {43 if (!m) return h('span', { class: 'muted' }, '-');44 const bits = [m.lab, m.openWeights ? 'open weights' : 'closed weights'];45 if (m.releaseDate) bits.push(m.releaseDate);46 return h(47 'span',48 { class: 'model-chip', title: bits.join(' - ') },49 h('span', { class: `weights-glyph ${m.openWeights ? 'open' : 'closed'}` }, m.openWeights ? '○' : '●'),50 ` ${m.name}`,51 );52}5354/**55 * Heart toggle with optimistic update. Signed-out: disabled with a56 * "sign in" tooltip (on a wrapper span — disabled buttons swallow hover).57 */58function heartButton(ctx: AppCtx, rec: { id: string; hearts: number; heartedByMe: boolean }): HTMLElement {59 let busy = false;60 const btn = h('button', { class: 'heart-btn', disabled: !ctx.user });61 const paint = () => {62 btn.textContent = `${rec.heartedByMe ? '♥' : '♡'} ${rec.hearts}`;63 btn.classList.toggle('hearted', rec.heartedByMe);64 btn.title = ctx.user ? (rec.heartedByMe ? 'unheart' : 'heart this script') : 'sign in to heart';65 };66 paint();67 btn.addEventListener('click', (e) => {68 e.stopPropagation();69 if (!ctx.user || busy) return;70 busy = true;71 const was = rec.heartedByMe;72 rec.heartedByMe = !was;73 rec.hearts += was ? -1 : 1;74 paint();75 void (was ? social.unheart(rec.id) : social.heart(rec.id))76 .catch(() => {77 rec.heartedByMe = was; // revert the optimistic flip78 rec.hearts += was ? 1 : -1;79 paint();80 })81 .finally(() => {82 busy = false;83 });84 });85 return h('span', { class: 'heart-wrap', title: ctx.user ? null : 'sign in to heart' }, btn);86}8788function scanningNotice(): HTMLElement {89 return h('p', { class: 'notice' }, 'scanning the fishbowl - ', h('span', { class: 'blink' }, '█'));90}9192// --------------------------------------------------------------------------93// List view94// --------------------------------------------------------------------------9596interface SortChip {97 label: string;98 sort: BrowseSort;99 window?: BrowseWindow;100}101102const CHIPS: SortChip[] = [103 { label: 'TOP', sort: 'top' },104 { label: '24H', sort: 'rising', window: '24h' },105 { label: '7D', sort: 'rising', window: '7d' },106 { label: '30D', sort: 'rising', window: '30d' },107 { label: '6M', sort: 'rising', window: '6m' },108 { label: 'UPDATED', sort: 'updated' },109 { label: 'MOST BATTLED', sort: 'battled' },110];111112const PAGE_LIMIT = 50;113114function renderList(main: HTMLElement, ctx: AppCtx): void {115 let chipIdx = 0;116 let items: PublicScriptListing[] = [];117 let offset = 0;118 let done = false;119 let seq = 0;120 let debounceTimer = 0;121122 // ---- sort chips123 const chipBtns = CHIPS.map((c, i) =>124 h(125 'button',126 {127 class: `mode-btn${i === chipIdx ? ' active' : ''}`,128 onclick: () => {129 chipIdx = i;130 chipBtns.forEach((b, j) => b.classList.toggle('active', j === chipIdx));131 void load(true);132 },133 },134 c.label,135 ),136 );137138 // ---- filters139 const requery = () => void load(true);140 const debounced = () => {141 window.clearTimeout(debounceTimer);142 debounceTimer = window.setTimeout(requery, 300);143 };144 const openSel = h(145 'select',146 { onchange: requery },147 h('option', { value: 'any' }, 'any weights'),148 h('option', { value: 'open' }, 'open'),149 h('option', { value: 'closed' }, 'closed'),150 );151 const labList = h('datalist', { id: 'browse-labs' });152 const labInput = h('input', { type: 'text', placeholder: 'lab', list: 'browse-labs', class: 'lab-input', oninput: debounced });153 const dateChanged = (el: HTMLInputElement) => () => {154 el.classList.toggle('has-value', el.value !== '');155 requery();156 };157 const afterInput = h('input', { type: 'date', class: 'date-input', title: 'model released after' });158 afterInput.addEventListener('change', dateChanged(afterInput));159 const beforeInput = h('input', { type: 'date', class: 'date-input', title: 'model released before' });160 beforeInput.addEventListener('change', dateChanged(beforeInput));161 const searchInput = h('input', { type: 'text', placeholder: 'search scripts', class: 'search-input', oninput: debounced });162163 // datalist of unique labs from the public model catalog164 void social165 .listModels({ limit: 200 })166 .then((models) => {167 const labs = [...new Set(models.map((m) => m.lab))].sort();168 labList.append(...labs.map((l) => h('option', { value: l })));169 })170 .catch(() => {171 /* datalist is a nicety — free-text still works */172 });173174 const listPane = h('div', { class: 'browse-scroll' });175 main.append(176 h(177 'div',178 { class: 'browse-wrap' },179 h(180 'div',181 { class: 'browse-head' },182 h('div', { class: 'row' }, h('span', { class: 'browse-title' }, '» BROWSE'), h('span', { class: 'mode-toggle chips-scroll' }, chipBtns)),183 h(184 'div',185 { class: 'row filter-row' },186 openSel,187 labInput,188 labList,189 h('span', { class: 'filter-label' }, 'released'),190 afterInput,191 h('span', { class: 'filter-label' }, '-'),192 beforeInput,193 searchInput,194 ),195 ),196 listPane,197 ),198 );199200 function currentQuery(): BrowseQuery {201 const chip = CHIPS[chipIdx] ?? CHIPS[0]!;202 const q: BrowseQuery = { sort: chip.sort, limit: PAGE_LIMIT, offset };203 if (chip.window) q.window = chip.window;204 if (openSel.value === 'open') q.openWeights = true;205 else if (openSel.value === 'closed') q.openWeights = false;206 if (labInput.value.trim()) q.lab = labInput.value.trim();207 if (afterInput.value) q.releasedAfter = afterInput.value;208 if (beforeInput.value) q.releasedBefore = beforeInput.value;209 if (searchInput.value.trim()) q.q = searchInput.value.trim();210 return q;211 }212213 async function load(reset: boolean): Promise<void> {214 const mySeq = ++seq;215 if (reset) {216 offset = 0;217 items = [];218 done = false;219 clear(listPane);220 listPane.append(scanningNotice());221 }222 try {223 const page = await social.browse(currentQuery());224 if (mySeq !== seq) return; // superseded by a newer query225 items = items.concat(page);226 done = page.length < PAGE_LIMIT;227 paint();228 } catch (err) {229 if (mySeq !== seq) return;230 clear(listPane);231 listPane.append(h('p', { class: 'error-text' }, errText(err)));232 }233 }234235 function paint(): void {236 clear(listPane);237 if (items.length === 0) {238 listPane.append(h('p', { class: 'notice' }, 'the fishbowl is empty - no tribes match'));239 return;240 }241 const windowed = (CHIPS[chipIdx] ?? CHIPS[0]!).sort === 'rising';242 const rows = items.map((item, i) => {243 const delta = item.windowDelta;244 return h(245 'tr',246 null,247 h('td', { class: 'mono muted rank-cell' }, `#${i + 1}`),248 h(249 'td',250 { class: 'title-cell' },251 identicon(item.id, item.title).el,252 h(253 'a',254 {255 href: `/browse/${item.id}`,256 onclick: (e: Event) => {257 e.preventDefault();258 ctx.navigate(`/browse/${item.id}`);259 },260 },261 item.title,262 ),263 item.sourceShared ? h('span', { class: 'src-badge', title: 'source shared' }, 'SRC') : null,264 ),265 h('td', { class: 'muted owner-cell' }, item.owner.name ?? item.owner.id),266 h('td', null, modelChip(item.model)),267 h(268 'td',269 { class: 'mono rating-cell' },270 String(Math.round(item.rating)),271 windowed ? h('span', { class: delta < 0 ? 'delta-neg' : 'delta-pos' }, ` ${delta < 0 ? '-' : '+'}${Math.abs(delta)}`) : null,272 ),273 h('td', { class: 'mono' }, `${item.wins}-${item.games - item.wins}`),274 h('td', { class: 'mono' }, String(item.battles)),275 h('td', { class: 'hearts-cell' }, heartButton(ctx, item)),276 );277 });278 listPane.append(279 h(280 'table',281 { class: 'list browse-table' },282 h(283 'thead',284 null,285 h(286 'tr',287 null,288 h('th', null, '#'),289 h('th', null, 'Script'),290 h('th', null, 'Owner'),291 h('th', null, 'Model'),292 h('th', null, windowed ? 'Rating Δ' : 'Rating'),293 h('th', null, 'W-L'),294 h('th', null, 'Battles'),295 h('th', null, 'Hearts'),296 ),297 ),298 h('tbody', null, rows),299 ),300 done301 ? h('p', { class: 'muted end-of-list' }, `- end of list (${items.length}) -`)302 : h(303 'div',304 { class: 'load-more-row' },305 h(306 'button',307 {308 onclick: (e: Event) => {309 (e.target as HTMLButtonElement).disabled = true;310 offset += PAGE_LIMIT;311 void load(false);312 },313 },314 'LOAD MORE',315 ),316 ),317 );318 }319320 void load(true);321}322323// --------------------------------------------------------------------------324// Detail view (/browse/:id)325// --------------------------------------------------------------------------326327function renderDetail(main: HTMLElement, ctx: AppCtx, id: string): void {328 const scroll = h('div', { class: 'browse-scroll detail-scroll' }, scanningNotice());329 const backLink = h(330 'a',331 {332 href: '/browse',333 class: 'back-link',334 onclick: (e: Event) => {335 e.preventDefault();336 ctx.navigate('/browse');337 },338 },339 '< BACK TO BROWSE',340 );341 main.append(h('div', { class: 'browse-wrap' }, h('div', { class: 'browse-head' }, h('div', { class: 'row' }, backLink)), scroll));342343 void social344 .getPublicScript(id)345 .then((detail) => {346 clear(scroll);347 paintDetail(scroll, ctx, detail);348 })349 .catch((err: unknown) => {350 clear(scroll);351 scroll.append(h('p', { class: 'error-text' }, errText(err)));352 });353}354355function paintDetail(scroll: HTMLElement, ctx: AppCtx, d: PublicScriptDetail): void {356 // ---- header card357 const head = h(358 'div',359 { class: 'card detail-card' },360 h('h2', { class: 'row detail-title' }, identicon(d.id, d.title, 30).el, d.title),361 h(362 'div',363 { class: 'row detail-meta' },364 h('span', { class: 'muted' }, `by ${d.owner.name ?? d.owner.id}`),365 modelChip(d.model),366 h('span', { class: 'mono' }, `rating ${Math.round(d.rating)}`),367 heartButton(ctx, d),368 d.sourceShared ? h('span', { class: 'src-badge', title: 'source shared' }, 'SRC') : null,369 h('span', { class: 'muted' }, `v${d.version} - updated ${fmtDate(d.updatedAt)}`),370 ),371 d.description ? h('p', { class: 'detail-desc' }, d.description) : null,372 h(373 'div',374 { class: 'kpi-row detail-kpis' },375 kpi('rating', String(Math.round(d.rating))),376 kpi('win rate', `${(d.aggregate.winRate / 10).toFixed(1)}%`),377 kpi('avg place', (d.aggregate.avgPlace / 100).toFixed(2)),378 kpi('games', String(d.games)),379 kpi('battles', String(d.battles)),380 ),381 );382383 // ---- proof pane: recent rated sims384 const proofCard = h('div', { class: 'card detail-card' }, h('h2', null, 'Proof'));385 if (d.proof.length === 0) {386 proofCard.append(h('p', { class: 'muted' }, 'No rated sims yet - battles will appear here.'));387 } else {388 proofCard.append(389 h(390 'div',391 { class: 'pane-scroll' },392 h(393 'table',394 { class: 'list' },395 h('thead', null, h('tr', null, h('th', null, 'Date'), h('th', null, 'Kind'), h('th', null, 'Result'), h('th'))),396 h(397 'tbody',398 null,399 d.proof.map((p) =>400 h(401 'tr',402 null,403 h('td', { class: 'muted' }, fmtDate(p.createdAt)),404 h('td', { class: 'mono' }, p.kind),405 h(406 'td',407 { class: 'proof-results' },408 // Chips, not sentences: one tile per tribe in place order,409 // crown on the winner, everything else lives in the hover.410 p.results.map((r) =>411 h(412 'span',413 {414 class: `proof-chip${r.scriptId === d.id ? ' self' : ''}${r.place === 1 ? ' winner' : ''}`,415 title: `${ordinal(r.place)} - ${r.title} - ${r.score} pts`,416 },417 r.place === 1 ? h('span', { class: 'crown' }, '♛') : null,418 identicon(r.scriptId, r.title).el,419 ),420 ),421 ),422 h('td', null, h('a', { href: p.url }, 'watch')),423 ),424 ),425 ),426 ),427 ),428 );429 }430431 // ---- aggregate pane: per-opponent record432 const aggCard = h('div', { class: 'card detail-card' }, h('h2', null, 'Head to head'));433 if (d.aggregate.opponents.length === 0) {434 aggCard.append(h('p', { class: 'muted' }, 'No opponents faced yet.'));435 } else {436 aggCard.append(437 h(438 'div',439 { class: 'pane-scroll' },440 h(441 'table',442 { class: 'list' },443 h('thead', null, h('tr', null, h('th', null, 'Opponent'), h('th', null, 'W-L'))),444 h(445 'tbody',446 null,447 d.aggregate.opponents.map((o) =>448 h(449 'tr',450 null,451 h(452 'td',453 null,454 h(455 'a',456 {457 href: `/browse/${o.scriptId}`,458 onclick: (e: Event) => {459 e.preventDefault();460 ctx.navigate(`/browse/${o.scriptId}`);461 },462 },463 o.title,464 ),465 ),466 h('td', { class: 'mono' }, `${o.wins}-${o.losses}`),467 ),468 ),469 ),470 ),471 ),472 );473 }474475 // ---- version history476 const verCard = h(477 'div',478 { class: 'card detail-card' },479 h('h2', null, 'Versions'),480 h(481 'div',482 { class: 'pane-scroll' },483 h(484 'table',485 { class: 'list' },486 h('thead', null, h('tr', null, h('th', null, 'Version'), h('th', null, 'Date'), h('th', null, 'Hash'))),487 h(488 'tbody',489 null,490 d.versions.map((v) =>491 h(492 'tr',493 null,494 h('td', { class: 'mono' }, `v${v.version}`),495 h('td', { class: 'muted' }, fmtDate(v.createdAt)),496 h('td', { class: 'mono', title: v.hash }, v.hash.slice(0, 10)),497 ),498 ),499 ),500 ),501 ),502 );503504 scroll.append(head, proofCard, aggCard, verCard);505506 // ---- shared source507 if (d.source !== undefined) {508 const pre = h('pre', { class: 'mono source-pane' });509 renderHighlighted(pre, d.source);510 scroll.append(h('div', { class: 'card detail-card' }, h('h2', null, 'Source'), pre));511 }512}513514function kpi(label: string, value: string): HTMLElement {515 return h('div', { class: 'kpi-tile' }, h('div', { class: 'kpi-label' }, label), h('div', { class: 'kpi-value' }, value));516}517518// --------------------------------------------------------------------------519// Minimal JS highlighter (local by design — no coupling to the editor page).520// Escapes HTML, then wraps comments/strings/numbers/keywords in spans.521// --------------------------------------------------------------------------522