import { escapeHtml } from '../util.js'; import { logoLockup } from '../logo.js'; import { renderCornerRail } from '../cornerRail.js'; import { attachTooltips } from '../tooltip.js'; import { renderTopRail } from '../topRail.js'; import { api, ApiError } from '../api.js'; import { getUser } from '../auth.js'; import { CLOSE_ICON } from './icons.js'; // Submitting a passage, and - for moderators - reviewing what others have // submitted. A bundled dataset stops growing at whatever fits in the // repository; this is how a corpus gets past that. const CATEGORIES = [ 'quotes', 'literature', 'movies', 'anime', 'science', 'history', 'technology', 'nature', 'philosophy', 'business', 'general', 'programming', 'shell', 'sysadmin', 'hacking', ]; // Mirrors the server's own limits, so the count turns red before a submission // is rejected rather than after. const MIN_CONTENT = 40; const MAX_CONTENT = 600; export function renderContributeScreen(root, { onBack, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) { let mine = []; let queue = []; let isModerator = false; let isAdmin = false; let roleHolders = []; let userSearch = []; let orders = []; let cleanupInner = null; function statusLabel(s) { if (s === 'approved') return 'Approved'; if (s === 'rejected') return 'Not accepted'; return 'Awaiting review'; } async function load() { if (!getUser()) return; try { mine = await api.get('/api/texts/mine'); } catch { mine = []; } // A 401 here simply means "not a moderator", which is the ordinary // case rather than an error worth showing. try { queue = await api.get('/api/texts/queue'); isModerator = true; } catch { queue = []; isModerator = false; } // Same shape: a 401 here means "not an administrator", which is the // ordinary case for almost everyone. try { roleHolders = await api.get('/api/admin/users'); isAdmin = true; } catch { roleHolders = []; isAdmin = false; } // Paid orders waiting to be posted. Only an administrator can see // them, and they carry a postal address, so this is the one screen in // the app that shows one. if (isAdmin) { try { orders = await api.get('/api/admin/orders'); } catch { orders = []; } } else { orders = []; } } function submissionRow(s, moderating) { return `
${escapeHtml(s.category)} ${moderating && s.submitted_by ? `by ${escapeHtml(s.submitted_by)}` : ''} ${statusLabel(s.status)}
${escapeHtml(s.content)}
${s.attribution ? `
— ${escapeHtml(s.attribution)}
` : ''} ${s.reject_reason ? `
${escapeHtml(s.reject_reason)}
` : ''} ${moderating ? `
` : ''}
`; } function render() { const user = getUser(); root.innerHTML = `
${logoLockup()}

Contribute a passage

${!user ? `
Sign in to submit a passage. Everything submitted is reviewed before anyone types it.
` : `
Something worth typing: a line from a book, a film, a song, or a command worth knowing. It is reviewed before it reaches anyone else.
0 / ${MAX_CONTENT}
${mine.length ? `

Your submissions

${mine.map(s => submissionRow(s, false)).join('')}
` : ''} ${isAdmin ? `

Moderators

Moderators review submitted passages. An administrator's own role is managed by the server, not here.
${(userSearch.length ? userSearch : roleHolders).map(u => `
${escapeHtml(u.username)} ${u.is_admin ? 'admin' : ''} ${u.is_moderator && !u.is_admin ? 'moderator' : ''} ${u.is_bot ? 'bot' : ''} ${u.is_admin ? '' : ``}
`).join('') || '
No moderators yet. Search for a user to appoint one.
'}
` : ''} ${isAdmin ? `

Orders to post${orders.length ? ` (${orders.length})` : ''}

Paid and not yet posted. Marking one sent does not notify the buyer.
${orders.length ? `
${orders.map(o => `
${escapeHtml(o.item)}${o.variant ? ` · ${escapeHtml(o.variant)}` : ''} for ${escapeHtml(o.username)} $${(o.amount_cents / 100).toFixed(2)}
${o.shipping_address ? escapeHtml(o.shipping_address).replace(/\n/g, '
') : 'No address recorded. Check the payment processor before posting.'}
`).join('')}
` : '
Nothing waiting to go out.
'} ` : ''} ${isModerator ? `

Review queue${queue.length ? ` (${queue.length})` : ''}

${queue.length ? `
${queue.map(s => submissionRow(s, true)).join('')}
` : `
Nothing waiting.
`} ` : ''} `}
`; root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack)); root.querySelector('[data-action="go-account"]')?.addEventListener('click', onShowAccount); attachTooltips(root); const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowLeaderboard, onShowMultiplayer, onShowStore }); cleanupInner = () => { cleanupTheme(); cleanupRail(); }; const form = root.querySelector('.contribute-form'); if (form) { const content = form.querySelector('.contribute-content'); const lenEl = form.querySelector('.contribute-len'); const errorEl = form.querySelector('.contribute-error'); const paint = () => { const n = content.value.trim().length; lenEl.textContent = String(n); lenEl.classList.toggle('bad', n > 0 && (n < MIN_CONTENT || n > MAX_CONTENT)); }; content.addEventListener('input', paint); paint(); form.addEventListener('submit', async e => { e.preventDefault(); errorEl.textContent = ''; try { await api.post('/api/texts', { category: form.querySelector('.contribute-category').value, content: content.value, attribution: form.querySelector('.contribute-attribution').value || null, }); await load(); render(); } catch (err) { errorEl.textContent = err instanceof ApiError ? err.message : 'Could not submit - try again.'; } }); } const searchForm = root.querySelector('.admin-search-form'); if (searchForm) { searchForm.addEventListener('submit', async e => { e.preventDefault(); const q = searchForm.querySelector('.admin-search').value.trim(); try { userSearch = q ? await api.get(`/api/admin/users?q=${encodeURIComponent(q)}`) : []; } catch { userSearch = []; } render(); }); } root.querySelectorAll('[data-action="set-role"]').forEach(btn => { btn.addEventListener('click', async () => { try { await api.post(`/api/admin/users/${encodeURIComponent(btn.dataset.username)}/role`, { moderator: btn.dataset.moderator === 'true' }); } catch { /* reloading below shows whatever actually happened */ } userSearch = []; await load(); render(); }); }); root.querySelectorAll('[data-action="mark-shipped"]').forEach(btn => { btn.addEventListener('click', async () => { try { await api.post(`/api/admin/orders/${encodeURIComponent(btn.dataset.order)}/shipped`); } catch { /* reloading below shows what actually happened */ } await load(); render(); }); }); root.querySelectorAll('.submission-actions button:not([data-action="mark-shipped"])').forEach(btn => { btn.addEventListener('click', async () => { const row = btn.closest('.submission-row'); const decision = btn.dataset.action === 'approve' ? 'approve' : 'reject'; const reason = decision === 'reject' ? window.prompt('Why is this not accepted? (optional, shown to the submitter)') || null : null; try { await api.post(`/api/texts/${row.dataset.id}/review`, { decision, reason }); await load(); render(); } catch { // Someone else reviewed it first; reloading shows the truth. await load(); render(); } }); }); } load().then(render); render(); return () => { if (cleanupInner) cleanupInner(); }; }