1/** Create: the script workshop — my scripts, upload, meta editor, source viewer, trash. */2import type { ModelRecord, ScriptRecord, ValidationReport } from '@tribes/protocol';3import { LIMITS } from '@tribes/protocol';4import { api } from '../api';5import { renderHighlighted } from '../highlight';6import { h, clear } from '../dom';7import type { AppCtx } from '../main';8import { signInCard, honorReturnRedirect } from './auth-card';910export function renderCreate(main: HTMLElement, ctx: AppCtx): void {11 if (!ctx.user) {12 main.append(signInCard(ctx));13 return;14 }15 // Signed in on the canonical auth origin on behalf of another host? Bounce back.16 if (honorReturnRedirect()) return;1718 const scriptsCard = h('div', { class: 'card' });19 const uploadCard = h('div', { class: 'card' });20 const trashCard = h('div', { class: 'card' });21 main.append(22 h('div', { class: 'home-grid create-grid' },23 h('div', { class: 'home-col' }, scriptsCard),24 h('div', { class: 'home-col' }, uploadCard, trashCard),25 ),26 );2728 // models.dev catalog: id -> record, for chips + the meta editor picker.29 const models = new Map<string, ModelRecord>();30 const rememberModels = (list: ModelRecord[]) => list.forEach((m) => models.set(m.id, m));3132 const refreshScripts = async () => {33 const scripts = await api.listLogic();34 paintScripts(scriptsCard, scripts, models, refreshAll);35 };36 const refreshTrash = async () => {37 paintTrash(trashCard, await api.listLogic({ trashed: true }), refreshAll);38 };39 const refreshAll = async () => {40 await Promise.all([refreshScripts(), refreshTrash()]);41 };4243 scriptsCard.append(h('h2', null, 'My scripts'), h('p', { class: 'muted' }, 'Loading…'));44 trashCard.append(h('h2', null, 'Trash'), h('p', { class: 'muted' }, 'Loading…'));45 paintUpload(uploadCard, refreshAll);4647 void api48 .listModels({ limit: 200 })49 .then(rememberModels)50 .catch(() => {/* catalog unavailable — chips fall back to raw ids */})51 .then(refreshScripts)52 .catch((e) => showErr(scriptsCard, e));53 void refreshTrash().catch((e) => showErr(trashCard, e));54}5556function showErr(card: HTMLElement, e: unknown) {57 card.append(h('p', { class: 'error-text' }, e instanceof Error ? e.message : String(e)));58}5960function fmtDate(iso: string | null): string {61 if (!iso) return '—';62 const d = new Date(iso);63 return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });64}6566function reportBox(report: ValidationReport): HTMLElement {67 return report.ok68 ? h('p', { class: 'ok-text' }, 'Validated ✓')69 : h('p', { class: 'error-text' }, `Rejected at ${report.failedStage ?? '?'}: ${report.detail ?? 'no detail'}`);70}7172function starButton(rec: { id: string; starred: boolean }, onToggle: () => void): HTMLElement {73 return h(74 'button',75 {76 class: `star-btn${rec.starred ? ' starred' : ''}`,77 title: rec.starred ? 'Unstar (allows expiry)' : `Star (pin, max ${LIMITS.MAX_STARS})`,78 onclick: async (e: Event) => {79 e.stopPropagation();80 try {81 if (rec.starred) await api.unstar('script', rec.id);82 else await api.star('script', rec.id);83 rec.starred = !rec.starred;84 } catch (err) {85 alert(err instanceof Error ? err.message : String(err));86 }87 onToggle();88 },89 },90 rec.starred ? '★' : '☆',91 );92}9394/** Model display name: catalog name, else the id's tail. */95function modelLabel(models: Map<string, ModelRecord>, id: string): string {96 return models.get(id)?.name ?? id.split('/').pop() ?? id;97}9899// --------------------------------------------------------------------------100// My scripts101// --------------------------------------------------------------------------102103function paintScripts(104 card: HTMLElement,105 scripts: ScriptRecord[],106 models: Map<string, ModelRecord>,107 refresh: () => Promise<void>,108) {109 clear(card);110 card.append(h('h2', null, 'My scripts'));111112 if (scripts.length === 0) {113 card.append(h('p', { class: 'muted' }, 'No scripts yet — upload one on the right, or connect your agent (Connect tab).'));114 return;115 }116117 const detail = h('div');118 // one open drawer at a time: 'view:<id>' | 'meta:<id>' | null119 let open: string | null = null;120121 const closeDetail = () => {122 open = null;123 clear(detail);124 };125126 const rows = scripts.map((s) => {127 const nameCell = h(128 'td',129 null,130 h('div', null, h('strong', null, s.title ?? s.name), s.title ? h('span', { class: 'mono muted' }, ` ${s.name}`) : null),131 );132 const badgesCell = h(133 'td',134 { class: 'badges-cell' },135 s.listed ? h('span', { class: 'badge listed' }, 'listed') : null,136 s.modelId ? h('span', { class: 'badge model-chip', title: s.modelId }, modelLabel(models, s.modelId)) : null,137 s.linkedScriptId ? h('span', { class: 'badge', title: `deduplicated to ${s.linkedScriptId}` }, 'linked') : null,138 );139 return h(140 'tr',141 null,142 h('td', null, starButton(s, () => void refresh())),143 nameCell,144 badgesCell,145 h('td', { class: 'mono' }, `v${s.version}`),146 // One compact cell, noteworthy states only: the star already means147 // pinned, and 'validated' is the unremarkable default.148 h(149 'td',150 { class: 'nowrap' },151 s.status === 'rejected' ? h('span', { class: 'error-text' }, 'rejected') : null,152 s.status !== 'rejected' && !s.starred153 ? h('span', { class: 'muted' }, `expires ${fmtDate(s.expiresAt)}`)154 : null,155 ),156 h(157 'td',158 { class: 'actions-cell' },159 h('button', { class: 'small', title: 'Download source', onclick: () => void download(s) }, 'get'),160 h('button', { class: 'small', title: 'View source', onclick: () => void toggle('view', s) }, 'view'),161 h('button', { class: 'small', title: 'Edit public metadata', onclick: () => void toggle('meta', s) }, 'meta'),162 h(163 'button',164 {165 class: 'small danger',166 title: 'Move to trash',167 onclick: async () => {168 if (!confirm(`Move "${s.name}" to trash for 14 days?`)) return;169 try {170 await api.deleteLogic(s.id);171 await refresh();172 } catch (err) {173 alert(err instanceof Error ? err.message : String(err));174 }175 },176 },177 'trash',178 ),179 ),180 );181 });182183 card.append(184 h(185 'table',186 { class: 'list' },187 h(188 'thead',189 null,190 h('tr', null, h('th'), h('th', null, 'Script'), h('th'), h('th', null, 'Ver'), h('th'), h('th')),191 ),192 h('tbody', null, rows),193 ),194 detail,195 );196197 async function download(s: ScriptRecord) {198 try {199 const source = await api.getLogicSource(s.id);200 const url = URL.createObjectURL(new Blob([source], { type: 'text/javascript' }));201 const a = h('a', { href: url, download: `${s.name}.js` });202 a.click();203 URL.revokeObjectURL(url);204 } catch (err) {205 alert(err instanceof Error ? err.message : String(err));206 }207 }208209 async function toggle(mode: 'view' | 'meta', s: ScriptRecord) {210 const key = `${mode}:${s.id}`;211 if (open === key) {212 closeDetail();213 return;214 }215 open = key;216 clear(detail);217 if (mode === 'view') await paintViewer(detail, s, closeDetail);218 else paintMetaEditor(detail, s, models, refresh, closeDetail);219 }220}221222// --------------------------------------------------------------------------223// Read-only source viewer (hand-rolled highlighter, no dependencies)224// --------------------------------------------------------------------------225226const JS_KEYWORDS = new Set(227 (228 'break case catch class const continue debugger default delete do else export extends finally for function ' +229 'if import in instanceof let new of return static super switch this throw try typeof var void while with yield ' +230 'async await get set true false null undefined NaN Infinity'231 ).split(' '),232);233234235async function paintViewer(host: HTMLElement, s: ScriptRecord, onClose: () => void) {236 host.append(h('p', { class: 'muted' }, `Loading ${s.name}…`));237 try {238 const source = await api.getLogicSource(s.id);239 clear(host);240 const pre = h('pre', { class: 'mono source-view' });241 renderHighlighted(pre, source);242 host.append(243 h(244 'div',245 { class: 'row', style: 'margin-top:0.8rem' },246 h('h3', { style: 'margin:0;border:none' }, `${s.name} `, h('span', { class: 'mono muted' }, `v${s.version} ${s.hash.slice(0, 12)}`)),247 h('span', { class: 'spacer', style: 'flex:1' }),248 h('button', { class: 'small', onclick: onClose }, 'close'),249 ),250 pre,251 );252 } catch (err) {253 clear(host);254 host.append(h('p', { class: 'error-text' }, err instanceof Error ? err.message : String(err)));255 }256}257258// --------------------------------------------------------------------------259// Meta editor260// --------------------------------------------------------------------------261262function paintMetaEditor(263 host: HTMLElement,264 s: ScriptRecord,265 models: Map<string, ModelRecord>,266 refresh: () => Promise<void>,267 onClose: () => void,268) {269 const msg = h('span');270 const title = h('input', { type: 'text', maxLength: 60, value: s.title ?? '', placeholder: 'shown in Browse (falls back to name)' });271 const description = h('textarea', { rows: 3, maxLength: 280, value: s.description ?? '', placeholder: 'one or two lines about the strategy (280 max)' });272 const sourceShared = h('input', { type: 'checkbox', checked: s.sourceShared });273274 // model picker: free-text input + datalist fed by GET /api/models?q= (debounced)275 const datalistId = `models-${s.id}`;276 const datalist = h('datalist', { id: datalistId });277 const modelInput = h('input', {278 type: 'text',279 list: datalistId,280 value: s.modelId ?? '',281 placeholder: 'models.dev id — type to search',282 });283 const fillDatalist = (list: ModelRecord[]) => {284 list.forEach((m) => models.set(m.id, m));285 clear(datalist);286 datalist.append(...list.map((m) => h('option', { value: m.id }, `${m.name} (${m.lab})`)));287 };288 fillDatalist([...models.values()]);289 let debounce: ReturnType<typeof setTimeout> | undefined;290 modelInput.addEventListener('input', () => {291 clearTimeout(debounce);292 const q = modelInput.value.trim();293 debounce = setTimeout(() => {294 void api295 .listModels(q ? { q, limit: 50 } : { limit: 200 })296 .then(fillDatalist)297 .catch(() => {/* catalog unavailable */});298 }, 250);299 });300301 host.append(302 h(303 'div',304 { class: 'meta-editor' },305 h(306 'div',307 { class: 'row' },308 h('h3', { style: 'margin:0;border:none' }, `Meta — ${s.name}`),309 h('span', { style: 'flex:1' }),310 h('button', { class: 'small', onclick: onClose }, 'close'),311 ),312 h('label', null, h('span', { class: 'field-label' }, 'Title (60)'), title),313 h('label', null, h('span', { class: 'field-label' }, 'Description (280)'), description),314 h(315 'label',316 { class: 'row' },317 sourceShared,318 h('span', null, 'Share source publicly — brag at your own risk'),319 ),320 h(321 'label',322 null,323 h('span', { class: 'field-label' }, 'Model'),324 modelInput,325 datalist,326 h(327 'span',328 { class: 'muted' },329 s.modelId ? `current: ${modelLabel(models, s.modelId)}` : 'current: none — blank clears',330 ),331 ),332 h(333 'div',334 { class: 'row' },335 h(336 'button',337 {338 class: 'primary',339 onclick: async () => {340 msg.className = 'muted';341 msg.textContent = 'Saving…';342 try {343 await api.patchLogicMeta(s.id, {344 title: title.value.trim() || null,345 description: description.value.trim() || null,346 sourceShared: sourceShared.checked,347 modelId: modelInput.value.trim() || null,348 });349 onClose();350 await refresh();351 } catch (err) {352 msg.className = 'error-text';353 msg.textContent = err instanceof Error ? err.message : String(err);354 }355 },356 },357 'Save',358 ),359 msg,360 ),361 ),362 );363}364365// --------------------------------------------------------------------------366// Upload367// --------------------------------------------------------------------------368369function paintUpload(card: HTMLElement, refresh: () => Promise<void>) {370 clear(card);371 const result = h('div');372 const nameInput = h('input', { type: 'text', placeholder: 'script name', maxLength: 64 });373 const fileInput = h('input', {374 type: 'file',375 accept: '.js,text/javascript',376 onchange: () => {377 const f = fileInput.files?.[0];378 if (f && !nameInput.value.trim()) {379 // prefill from filename, coerced to the allowed charset380 nameInput.value = f.name.replace(/\.js$/i, '').replace(/[^\w .-]+/g, '-').slice(0, 64);381 }382 },383 });384 card.append(385 h('h2', null, 'Upload'),386 h('p', { class: 'muted' }, 'Upload a .js tribe script. Uploading with an existing name bumps that script to the next version.'),387 h('div', { class: 'row', style: 'margin-bottom:0.4rem' }, fileInput),388 h('div', { class: 'row', style: 'margin-bottom:0.4rem' }, nameInput),389 h(390 'div',391 { class: 'row' },392 h(393 'button',394 {395 class: 'primary',396 onclick: async () => {397 clear(result);398 const f = fileInput.files?.[0];399 const name = nameInput.value.trim();400 if (!f || !name) {401 result.append(h('p', { class: 'error-text' }, 'Pick a .js file and a name.'));402 return;403 }404 try {405 const res = await api.submitLogic(name, await f.text());406 result.append(reportBox(res.report));407 await refresh();408 } catch (err) {409 result.append(h('p', { class: 'error-text' }, err instanceof Error ? err.message : String(err)));410 }411 },412 },413 'Upload script',414 ),415 ),416 result,417 );418}419420// --------------------------------------------------------------------------421// Trash422// --------------------------------------------------------------------------423424function daysLeft(trashedUntil: string | null): number {425 if (!trashedUntil) return 0;426 return Math.max(0, Math.ceil((new Date(trashedUntil).getTime() - Date.now()) / 86_400_000));427}428429function paintTrash(card: HTMLElement, trashed: ScriptRecord[], refresh: () => Promise<void>) {430 clear(card);431 card.append(h('h2', null, 'Trash'));432 if (trashed.length === 0) {433 card.append(h('p', { class: 'muted' }, 'Trash is empty. Trashed scripts are purged after 14 days.'));434 return;435 }436 const msg = h('p', { class: 'error-text' });437 card.append(438 h(439 'div',440 null,441 trashed.map((s) => {442 const d = daysLeft(s.trashedUntil);443 return h(444 'div',445 { class: 'trash-row row' },446 h('span', null, h('strong', null, s.title ?? s.name), h('span', { class: 'mono muted' }, ` v${s.version}`)),447 h('span', { class: 'trash-days' }, `${d} day${d === 1 ? '' : 's'} left`),448 h('span', { style: 'flex:1' }),449 h(450 'button',451 {452 class: 'small',453 onclick: async () => {454 msg.textContent = '';455 try {456 await api.restoreLogic(s.id);457 await refresh();458 } catch (err) {459 msg.textContent = err instanceof Error ? err.message : String(err);460 }461 },462 },463 'restore',464 ),465 );466 }),467 ),468 msg,469 );470}