import { attachTypeahead } from './components/HandleTypeahead.js'; import { bskyPostUrl } from './api.js'; import { resultsGrid, RESULTS_CSS } from './results.js'; import { FOOTER_HTML, FOOTER_CSS } from './footer.js'; import { esc } from './utils.js'; import { createElement, ArrowRight, Plus, User } from 'lucide'; // the shared results + footer components ship their own css (also used // verbatim by the worker's public pages) const resultsStyle = document.createElement('style'); resultsStyle.textContent = RESULTS_CSS + FOOTER_CSS; document.head.appendChild(resultsStyle); // footer lives inside main-screen so it only shows once signed in document.getElementById('main-screen').insertAdjacentHTML('beforeend', FOOTER_HTML); const ARROW_RIGHT_HTML = createElement(ArrowRight, { width: 16, height: 16, 'stroke-width': 2, class: 'inline align-text-bottom ml-1' }).outerHTML; const PLUS_HTML = createElement(Plus, { width: 14, height: 14, 'stroke-width': 2, class: 'shrink-0' }).outerHTML; const OPTION_INPUT_CLASS = 'option-input block w-full text-base p-1.5 bg-stone-100 dark:bg-stone-800 outline-none mt-2 resize-none overflow-hidden'; const MAX_OPTIONS = 12; export function showLoading(msg) { document.getElementById('login-screen').style.display = 'none'; document.getElementById('main-screen').style.display = 'none'; const ls = document.getElementById('loading-screen'); ls.style.display = 'block'; ls.textContent = msg || 'loading…'; } export function showMain() { document.getElementById('login-screen').style.display = 'none'; document.getElementById('loading-screen').style.display = 'none'; document.getElementById('main-screen').style.display = 'block'; } export function setWhoami(profile) { const el = document.getElementById('whoami'); if (!el) return; el.title = `@${profile.handle}`; el.className = 'flex items-center gap-2 min-w-0 text-gray-600 dark:text-gray-400'; const avatar = profile.avatar ? `@${esc(profile.handle)}` : ``; el.innerHTML = avatar + `${esc(profile.displayName || '@' + profile.handle)}`; const slot = el.querySelector('.avatar-slot'); if (slot) slot.appendChild(createElement(User, { width: 16, height: 16, 'stroke-width': 2 })); } export function showLoginError(msg) { document.getElementById('login-error').textContent = msg || ''; } export function showError(msg) { document.getElementById('error').textContent = msg || ''; } export function setLoginLoading(isLoading) { const btn = document.getElementById('login-btn'); btn.disabled = isLoading; btn.innerHTML = isLoading ? 'redirecting…' : `sign in${ARROW_RIGHT_HTML}`; if (isLoading) showLoginError(''); } export function setPublishLoading(isLoading) { const btn = document.getElementById('create-poll-btn'); btn.disabled = isLoading; btn.textContent = isLoading ? 'publishing…' : 'publish poll'; } // bluesky-composer-style budget donut: fills as you type, shows the // remaining count when it gets close, goes red with the overage when over export function updatePostBudget(graphemes, max) { const el = document.getElementById('post-budget'); if (!el) return; const remaining = max - graphemes; const over = remaining < 0; const r = 7; const c = 2 * Math.PI * r; const frac = Math.min(graphemes / max, 1); el.className = `flex items-center gap-1.5 tabular-nums text-sm ${over ? 'text-red-500' : 'text-gray-600 dark:text-gray-400'}`; el.innerHTML = (over || remaining <= 20 ? `${remaining}` : '') + ` `; const btn = document.getElementById('create-poll-btn'); btn.disabled = over; btn.classList.toggle('opacity-40', over); } export function setPollsDimmed(dimmed) { const classList = document.getElementById('polls').classList; if (dimmed) { classList.add('opacity-40', 'pointer-events-none'); } else { classList.remove('opacity-40', 'pointer-events-none'); } } export function readPollForm() { const question = document.getElementById('poll-question').value.trim(); const options = [...document.querySelectorAll('.option-input')] .map(i => i.value.trim()) .filter(Boolean); return { question, options }; } export function resetPollForm() { const q = document.getElementById('poll-question'); q.value = ''; autoGrow(q); const container = document.getElementById('option-inputs'); container.innerHTML = ''; addOptionInput(); addOptionInput(); } function autoGrow(el) { el.style.height = 'auto'; el.style.height = el.scrollHeight + 'px'; } function addOptionInput() { const container = document.getElementById('option-inputs'); if (container.children.length >= MAX_OPTIONS) return; const input = document.createElement('textarea'); input.rows = 1; input.className = OPTION_INPUT_CLASS; input.placeholder = `option ${container.children.length + 1}`; input.autocomplete = 'off'; // options are single-line (they wrap visually, but hold no newlines — // each becomes one facet line in the post); the question allows breaks input.addEventListener('keydown', e => { if (e.key === 'Enter') e.preventDefault(); }); input.addEventListener('input', () => { if (input.value.includes('\n')) input.value = input.value.replace(/\s*\n\s*/g, ' '); autoGrow(input); }); container.appendChild(input); document.getElementById('add-option-btn').style.display = container.children.length >= MAX_OPTIONS ? 'none' : ''; } // polls: [{ uri, question, createdAt, options: [{ uri, cid, text, count }] }] export function renderPolls(polls, onDelete) { const el = document.getElementById('polls'); el.innerHTML = ''; if (!polls.length) { el.innerHTML = ''; return; } polls.forEach(poll => { const total = poll.options.reduce((sum, o) => sum + o.count, 0); const card = document.createElement('div'); card.className = 'mb-12 break-words'; card.innerHTML = `
${esc(poll.question)}
` + `
` + `
${total.toLocaleString()} votes
` + `` + (poll.postUri ? `view` : '') + `delete` + `` + `
` + resultsGrid(poll.options, total); if (poll.votedFor) { const mine = card.querySelector(`.opt-text[data-option="${CSS.escape(poll.votedFor)}"]`); if (mine) mine.textContent += ' ←'; } el.appendChild(card); }); // destructive, so two-step: [delete] arms into really?, second click fires el.querySelectorAll('.delete-btn').forEach(btn => { btn.addEventListener('click', () => { if (btn.dataset.armed) { onDelete(btn.dataset.poll); } else { btn.dataset.armed = '1'; btn.textContent = 'really?'; btn.classList.add('text-red-500'); } }); btn.addEventListener('mouseleave', () => { delete btn.dataset.armed; btn.textContent = 'delete'; btn.classList.remove('text-red-500'); }); }); } export function bindEvents({ onLogin, onLogout, onCreatePoll, onPollInput }) { setLoginLoading(false); const handleInput = document.getElementById('handle'); document.getElementById('login-btn').addEventListener('click', () => { onLogin(handleInput.value.trim()); }); attachTypeahead(handleInput, document.getElementById('handle-typeahead'), { onSubmit: v => onLogin(v), }); document.getElementById('logout-btn').addEventListener('click', onLogout); const question = document.getElementById('poll-question'); question.addEventListener('input', () => autoGrow(question)); const addBtn = document.getElementById('add-option-btn'); addBtn.innerHTML = `${PLUS_HTML} add option`; addBtn.addEventListener('click', addOptionInput); addOptionInput(); addOptionInput(); document.getElementById('create-poll-btn').addEventListener('click', () => { onCreatePoll(readPollForm()); }); if (onPollInput) { document.getElementById('poll-form').addEventListener('input', onPollInput); onPollInput(); } }