diff --git a/AGENTS.md b/AGENTS.md index 1c78885..a3ea8f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,7 @@ ## Current Architecture - Active frontend: `site/` (`index.html`, `nostr-client.js`, `nostr-client.css`, `locales.js`) +- Bulletin board: `site/board.html` + `site/board.js` (standalone page listing recent flyers from the relays; escapes untrusted relay content and rejects non-http(s) link URLs) - Static deployment root: `site` - Browser dependencies: vendored under `site/vendor/` - Legacy path: `site/nostr/index.html` is a redirect stub forwarding old `/nostr/` poster URLs and QR codes to the root diff --git a/README.md b/README.md index 6b53358..9f63e2a 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,13 @@ The previous Tor/OnionShare CLI version is preserved on the `legacy-tor` branch. - **US Letter and A4.** Print size defaults to the right paper for the visitor's country (from the browser locale's region — `en-US` → Letter, `en-GB` → A4, no geolocation), with a manual override. Paper size is a local print preference and is never written into the event. See the [spec](docs/nostr-static-client-spec.md#print-paper-size). - **Fits-the-page editing.** Fields that would overflow the printable sheet are rejected as you type, with an inline error under the affected field. See the [field length cheat sheet](docs/flyer-field-limits.md) for how much text fits each field per language and paper size. - **QR codes.** The tear-off QR points at the poster URL (re-open and reprint); the main flyer QR points at the creator's content URL. +- **Bulletin board.** A small screen-only link below the flyer preview (outside the printed sheet, never printed) opens a spreadsheet-style page of the most recent flyers found on the relays — title, link, posting npub, language, and date — sortable by any column. - **Self-contained.** No build step and no CDNs — Nostr and QR libraries are vendored under `site/vendor/`. ## What Is In `main` - `site/index.html`, `site/nostr-client.js`, `site/nostr-client.css`, `site/locales.js`: static editor/viewer client +- `site/board.html`, `site/board.js`: bulletin-board page listing recent flyers from the relays - `site/vendor/`: vendored browser dependencies for Nostr and QR generation - `site/nostr/index.html`: redirect stub so legacy `/nostr/` poster URLs and QR codes still resolve - `site/CNAME`: custom-domain target diff --git a/docs/nostr-static-client-spec.md b/docs/nostr-static-client-spec.md index 637339d..8355be5 100644 --- a/docs/nostr-static-client-spec.md +++ b/docs/nostr-static-client-spec.md @@ -26,6 +26,8 @@ site/ nostr-client.css nostr-client.js locales.js + board.html (bulletin board page) + board.js CNAME vendor/ nostr-tools/ @@ -152,6 +154,32 @@ including the EU, Russia, China, Japan, and the Middle East). Field capacity therefore varies by paper size; see [`flyer-field-limits.md`](flyer-field-limits.md). +## Bulletin board + +`site/board.html` + `site/board.js` are a standalone page that lists the most +recent flyers found on the default relays as a sortable, spreadsheet-style +table: **title**, **link** (the poster URL, rendered as the word "link"), +**posting npub**, **language**, and **posted date**. Any column header sorts +(default is date descending); replaceable events are de-duplicated per +author+`d` identifier, keeping the latest. + +- The entry point is a small screen-only link below the flyer preview + (`.board-link`, label `bulletin_board`), outside the printable sheet. It must + never print (hidden in the `@media print` block alongside the other app + chrome). +- `board.js` is independent of `nostr-client.js` (which exports nothing), so its + UI strings live in `BOARD_UI` (all supported languages); it reuses the + vendored nostr-tools and `locales.js` (for language names + RTL direction). +- The board has its own language selector. It reads/writes the same + `voxvera_nostr_lang` localStorage key as the main client, so the chosen + language carries across both pages, and switching it re-localizes the chrome, + column headers, language labels, and date formatting (and flips RTL). +- Relay content is untrusted: titles and npubs are escaped, and link URLs are + rejected unless `http:`/`https:` (so `javascript:`/`data:` never render as a + link). +- The npub column is forward-looking — anonymous keys today, but a stable + identity per author maps cleanly onto this view later. + ## Localization Flyer defaults come from `site/locales.js`. diff --git a/e2e/flyer.spec.js b/e2e/flyer.spec.js index f109394..c6f1f05 100644 --- a/e2e/flyer.spec.js +++ b/e2e/flyer.spec.js @@ -146,4 +146,48 @@ test.describe("VoxVera static client", () => { // Stored override beats the en-US region default. await expect(page.locator("html")).toHaveClass(/paper-a4/); }); + + test("bulletin board link sits outside the flyer sheet and is not printed", async ({ page }) => { + await page.goto("/"); + const link = page.locator(".preview-band > .board-link a"); + await expect(link).toHaveText("Bulletin board"); + await expect(link).toHaveAttribute("href", "board.html"); + // It is app chrome below the preview, not part of the printable sheet. + await expect(page.locator(".container .board-link")).toHaveCount(0); + // Screen-only — it must not appear in print output. + await page.emulateMedia({ media: "print" }); + await expect(page.locator(".board-link")).toBeHidden(); + }); + + test("bulletin board page renders localized, sortable columns", async ({ page }) => { + await page.goto("/board.html"); + await expect(page.locator("#board-title")).toHaveText("Bulletin Board"); + const headers = page.locator("#board-head th"); + await expect(headers).toHaveCount(5); + await expect(headers.nth(0)).toContainText("Title"); + await expect(headers.nth(1)).toContainText("Link"); + await expect(headers.nth(2)).toContainText("Posted by"); + await expect(headers.nth(3)).toContainText("Language"); + await expect(headers.nth(4)).toContainText("Posted"); + // Default sort is date descending. + await expect(headers.nth(4).locator(".sort-indicator")).toContainText("▼"); + // Clicking the Title header makes it the active ascending sort. + await headers.nth(0).click(); + await expect(headers.nth(0).locator(".sort-indicator")).toContainText("▲"); + }); + + test("bulletin board language selector localizes the page", async ({ page }) => { + await page.goto("/board.html"); + await expect(page.locator("#board-title")).toHaveText("Bulletin Board"); + await page.locator("#board-lang").selectOption("de"); + await expect(page.locator("#board-title")).toHaveText("Schwarzes Brett"); + await expect(page.locator("#board-head th").first()).toContainText("Titel"); + // An RTL language flips the document direction. + await page.locator("#board-lang").selectOption("ar"); + await expect(page.locator("html")).toHaveAttribute("dir", "rtl"); + await expect(page.locator("#board-title")).toHaveText("لوحة الإعلانات"); + // The choice persists (shared key) and survives a reload. + await page.reload(); + await expect(page.locator("#board-title")).toHaveText("لوحة الإعلانات"); + }); }); diff --git a/site/board.html b/site/board.html new file mode 100644 index 0000000..b9b5e47 --- /dev/null +++ b/site/board.html @@ -0,0 +1,31 @@ + + + + + + VoxVera — Bulletin Board + + + +
+
+

Bulletin Board

+

Most recent flyers published to the relays.

+
+
+ Back to VoxVera + +
+
+
+

Loading…

+ + + +
+
+ + + + + diff --git a/site/board.js b/site/board.js new file mode 100644 index 0000000..f3fc3e1 --- /dev/null +++ b/site/board.js @@ -0,0 +1,306 @@ +(function () { + "use strict"; + + // Standalone bulletin-board page. It reuses the vendored nostr-tools and the + // shared locale data, but is independent of nostr-client.js (which is an IIFE + // and exports nothing), so its UI strings live in BOARD_UI below. + + const DEFAULT_RELAYS = [ + "wss://relay.damus.io", + "wss://nos.lol", + "wss://relay.primal.net" + ]; + const EVENT_KIND = 30078; + const UI_LANG_STORAGE_KEY = "voxvera_nostr_lang"; + const FALLBACK_LANG = "en"; + const LOCALES = window.VoxVeraLocales || {}; + + const BOARD_UI = { + en: { title: "Bulletin Board", subtitle: "Most recent flyers published to the relays.", col_title: "Title", col_link: "Link", col_author: "Posted by", col_language: "Language", col_date: "Posted", link_text: "link", loading: "Loading flyers…", empty: "No flyers found yet.", error: "Could not reach the relays.", back: "← Back to VoxVera" }, + es: { title: "Tablón de anuncios", subtitle: "Carteles más recientes publicados en los relés.", col_title: "Título", col_link: "Enlace", col_author: "Publicado por", col_language: "Idioma", col_date: "Publicado", link_text: "enlace", loading: "Cargando carteles…", empty: "Aún no se encontraron carteles.", error: "No se pudo conectar con los relés.", back: "← Volver a VoxVera" }, + de: { title: "Schwarzes Brett", subtitle: "Neueste auf den Relays veröffentlichte Plakate.", col_title: "Titel", col_link: "Link", col_author: "Veröffentlicht von", col_language: "Sprache", col_date: "Veröffentlicht", link_text: "Link", loading: "Plakate werden geladen…", empty: "Noch keine Plakate gefunden.", error: "Relays konnten nicht erreicht werden.", back: "← Zurück zu VoxVera" }, + fr: { title: "Tableau d'affichage", subtitle: "Affiches les plus récentes publiées sur les relais.", col_title: "Titre", col_link: "Lien", col_author: "Publié par", col_language: "Langue", col_date: "Publié", link_text: "lien", loading: "Chargement des affiches…", empty: "Aucune affiche trouvée pour l'instant.", error: "Impossible de joindre les relais.", back: "← Retour à VoxVera" }, + ru: { title: "Доска объявлений", subtitle: "Последние листовки, опубликованные на реле.", col_title: "Заголовок", col_link: "Ссылка", col_author: "Опубликовал", col_language: "Язык", col_date: "Опубликовано", link_text: "ссылка", loading: "Загрузка листовок…", empty: "Листовки пока не найдены.", error: "Не удалось подключиться к реле.", back: "← Назад в VoxVera" }, + he: { title: "לוח מודעות", subtitle: "הכרזות האחרונות שפורסמו לממסרים.", col_title: "כותרת", col_link: "קישור", col_author: "פורסם על ידי", col_language: "שפה", col_date: "פורסם", link_text: "קישור", loading: "טוען כרזות…", empty: "לא נמצאו כרזות עדיין.", error: "לא ניתן להתחבר לממסרים.", back: "← חזרה ל-VoxVera" }, + ar: { title: "لوحة الإعلانات", subtitle: "أحدث الملصقات المنشورة على المرحلات.", col_title: "العنوان", col_link: "رابط", col_author: "نُشر بواسطة", col_language: "اللغة", col_date: "تاريخ النشر", link_text: "رابط", loading: "جارٍ تحميل الملصقات…", empty: "لم يتم العثور على ملصقات بعد.", error: "تعذّر الوصول إلى المرحلات.", back: "← العودة إلى VoxVera" }, + fa: { title: "تابلوی اعلانات", subtitle: "جدیدترین پوسترهای منتشرشده روی رله‌ها.", col_title: "عنوان", col_link: "پیوند", col_author: "منتشرشده توسط", col_language: "زبان", col_date: "تاریخ انتشار", link_text: "پیوند", loading: "در حال بارگذاری پوسترها…", empty: "هنوز پوستری یافت نشد.", error: "اتصال به رله‌ها ممکن نشد.", back: "← بازگشت به VoxVera" }, + hi: { title: "बुलेटिन बोर्ड", subtitle: "रिले पर प्रकाशित सबसे हाल के पोस्टर।", col_title: "शीर्षक", col_link: "लिंक", col_author: "द्वारा पोस्ट किया गया", col_language: "भाषा", col_date: "पोस्ट किया गया", link_text: "लिंक", loading: "पोस्टर लोड हो रहे हैं…", empty: "अभी तक कोई पोस्टर नहीं मिला।", error: "रिले तक नहीं पहुँच सके।", back: "← VoxVera पर वापस" }, + ja: { title: "掲示板", subtitle: "リレーに公開された最新のちらし。", col_title: "タイトル", col_link: "リンク", col_author: "投稿者", col_language: "言語", col_date: "投稿日", link_text: "リンク", loading: "ちらしを読み込んでいます…", empty: "まだちらしが見つかりません。", error: "リレーに接続できませんでした。", back: "← VoxVera に戻る" }, + pt: { title: "Quadro de avisos", subtitle: "Panfletos mais recentes publicados nos relés.", col_title: "Título", col_link: "Link", col_author: "Publicado por", col_language: "Idioma", col_date: "Publicado", link_text: "link", loading: "Carregando panfletos…", empty: "Nenhum panfleto encontrado ainda.", error: "Não foi possível acessar os relés.", back: "← Voltar ao VoxVera" }, + sw: { title: "Ubao wa matangazo", subtitle: "Mabango ya hivi karibuni yaliyochapishwa kwenye relay.", col_title: "Kichwa", col_link: "Kiungo", col_author: "Imechapishwa na", col_language: "Lugha", col_date: "Imechapishwa", link_text: "kiungo", loading: "Inapakia mabango…", empty: "Hakuna mabango yaliyopatikana bado.", error: "Imeshindwa kufikia relay.", back: "← Rudi VoxVera" }, + tr: { title: "İlan panosu", subtitle: "Rölelere yayımlanan en yeni el ilanları.", col_title: "Başlık", col_link: "Bağlantı", col_author: "Yayımlayan", col_language: "Dil", col_date: "Yayımlandı", link_text: "bağlantı", loading: "El ilanları yükleniyor…", empty: "Henüz el ilanı bulunamadı.", error: "Rölelere ulaşılamadı.", back: "← VoxVera'ya dön" }, + zh: { title: "公告栏", subtitle: "发布到中继的最新传单。", col_title: "标题", col_link: "链接", col_author: "发布者", col_language: "语言", col_date: "发布时间", link_text: "链接", loading: "正在加载传单…", empty: "尚未找到传单。", error: "无法连接到中继。", back: "← 返回 VoxVera" } + }; + + function pickLang() { + try { + const stored = window.localStorage.getItem(UI_LANG_STORAGE_KEY); + if (stored && BOARD_UI[stored]) return stored; + } catch (_) {} + const list = (navigator.languages && navigator.languages.length) + ? navigator.languages + : [navigator.language || ""]; + for (const tag of list) { + const base = String(tag || "").split("-")[0]; + if (BOARD_UI[base]) return base; + } + return FALLBACK_LANG; + } + + let currentLang = pickLang(); + function t(key) { + return (BOARD_UI[currentLang] && BOARD_UI[currentLang][key]) || BOARD_UI[FALLBACK_LANG][key] || key; + } + + function escapeHtml(value) { + return String(value == null ? "" : value).replace(/[&<>"']/g, (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); + } + + function isSafeUrl(url) { + try { + const u = new URL(url, window.location.href); + return u.protocol === "http:" || u.protocol === "https:"; + } catch (_) { + return false; + } + } + + function langLabel(code) { + const meta = LOCALES[code] && LOCALES[code].meta; + if (meta && meta.language_name) { + return (meta.flag ? meta.flag + " " : "") + meta.language_name; + } + return code || "—"; + } + + function shortNpub(npub) { + return npub && npub.length > 20 ? `${npub.slice(0, 12)}…${npub.slice(-6)}` : (npub || "—"); + } + + function fmtDate(ts) { + if (!ts) return "—"; + try { + return new Date(ts * 1000).toLocaleString(currentLang); + } catch (_) { + return new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " "); + } + } + + function dtagOf(tags) { + const d = Array.isArray(tags) && tags.find((x) => x[0] === "d"); + return d ? d[1] : ""; + } + + function langFromTags(tags) { + if (!Array.isArray(tags)) return ""; + const lt = tags.find((x) => x[0] === "language" && x[1]) || tags.find((x) => x[0] === "l" && x[1]); + return lt ? lt[1] : ""; + } + + // Fetch recent VoxVera flyer events across the default relays. Resolves with + // the collected raw events plus whether any relay connection opened (so we can + // tell "no flyers" apart from "relays unreachable"). + function queryRelays(relays, timeoutMs) { + return new Promise((resolve) => { + const sub = "voxvera-board-" + Math.random().toString(16).slice(2); + const events = new Map(); + let remaining = relays.length; + let anyOpen = false; + let settled = false; + const sockets = []; + const finish = () => { + if (settled) return; + settled = true; + sockets.forEach((s) => { + try { s.send(JSON.stringify(["CLOSE", sub])); s.close(); } catch (_) {} + }); + resolve({ events: Array.from(events.values()), anyOpen }); + }; + relays.forEach((relay) => { + let ws; + try { ws = new WebSocket(relay); } catch (_) { remaining -= 1; if (remaining <= 0) finish(); return; } + sockets.push(ws); + ws.onopen = () => { + anyOpen = true; + ws.send(JSON.stringify(["REQ", sub, { kinds: [EVENT_KIND], "#t": ["voxvera"], limit: 200 }])); + }; + ws.onmessage = (message) => { + try { + const data = JSON.parse(message.data); + if (data[0] === "EVENT" && data[2] && data[2].id && !events.has(data[2].id)) { + events.set(data[2].id, data[2]); + } else if (data[0] === "EOSE") { + remaining -= 1; + if (remaining <= 0) finish(); + } + } catch (_) {} + }; + ws.onerror = () => { remaining -= 1; if (remaining <= 0) finish(); }; + }); + setTimeout(finish, timeoutMs || 6000); + }); + } + + // Reduce raw events to the latest flyer per author+identifier (replaceable). + function parseFlyers(events) { + const tools = window.NostrTools; + const byKey = new Map(); + events.forEach((e) => { + if (!e || e.kind !== EVENT_KIND) return; + const tags = e.tags || []; + if (!tags.some((x) => x[0] === "t" && x[1] === "voxvera")) return; + let payload; + try { payload = JSON.parse(e.content); } catch (_) { return; } + const key = e.pubkey + ":" + dtagOf(tags); + const prev = byKey.get(key); + if (prev && prev.created_at >= (e.created_at || 0)) return; + let npub = e.pubkey; + try { npub = tools.nip19.npubEncode(e.pubkey); } catch (_) {} + byKey.set(key, { + title: String(payload.title || "").trim(), + link: String(payload.tear_off_link || payload.url || "").trim(), + npub: npub, + lang: payload.lang || langFromTags(tags) || "", + created_at: e.created_at || 0 + }); + }); + return Array.from(byKey.values()); + } + + let rows = []; + let sortKey = "date"; + let sortDir = "desc"; + let statusKey = "loading"; + + const COLS = [ + { key: "title", label: () => t("col_title"), + cell: (r) => `${escapeHtml(r.title || "—")}`, + cmp: (a, b) => (a.title || "").localeCompare(b.title || "") }, + { key: "link", label: () => t("col_link"), + cell: (r) => isSafeUrl(r.link) + ? `${escapeHtml(t("link_text"))}` + : "—", + cmp: (a, b) => (a.link || "").localeCompare(b.link || "") }, + { key: "author", label: () => t("col_author"), + cell: (r) => `${escapeHtml(shortNpub(r.npub))}`, + cmp: (a, b) => (a.npub || "").localeCompare(b.npub || "") }, + { key: "language", label: () => t("col_language"), + cell: (r) => escapeHtml(langLabel(r.lang)), + cmp: (a, b) => langLabel(a.lang).localeCompare(langLabel(b.lang)) }, + { key: "date", label: () => t("col_date"), + cell: (r) => `${escapeHtml(fmtDate(r.created_at))}`, + cmp: (a, b) => (a.created_at || 0) - (b.created_at || 0) } + ]; + + function renderHead() { + const head = document.getElementById("board-head"); + head.innerHTML = COLS.map((c) => { + const ind = c.key === sortKey ? (sortDir === "asc" ? " ▲" : " ▼") : ""; + return `${escapeHtml(c.label())}${ind}`; + }).join(""); + head.querySelectorAll("th").forEach((th) => { + th.addEventListener("click", () => { + const k = th.dataset.key; + if (k === sortKey) { + sortDir = sortDir === "asc" ? "desc" : "asc"; + } else { + sortKey = k; + sortDir = k === "date" ? "desc" : "asc"; + } + renderHead(); + renderBody(); + }); + }); + } + + function renderBody() { + const col = COLS.find((c) => c.key === sortKey) || COLS[0]; + const sorted = rows.slice().sort((a, b) => { + const r = col.cmp(a, b); + return sortDir === "asc" ? r : -r; + }); + document.getElementById("board-rows").innerHTML = sorted + .map((r) => `${COLS.map((c) => `${c.cell(r)}`).join("")}`) + .join(""); + } + + function setText(id, value) { + const node = document.getElementById(id); + if (node) node.textContent = value; + } + + function setStatus(key) { + statusKey = key; + const node = document.getElementById("board-status"); + if (!node) return; + if (key) { + node.hidden = false; + node.textContent = t(key); + } else { + node.hidden = true; + } + } + + function populateLangSelect() { + const sel = document.getElementById("board-lang"); + if (!sel || sel.options.length) return; + Object.keys(LOCALES) + .filter((code) => BOARD_UI[code]) + .sort((a, b) => { + const an = (LOCALES[a].meta && LOCALES[a].meta.language_name) || a; + const bn = (LOCALES[b].meta && LOCALES[b].meta.language_name) || b; + return an.localeCompare(bn); + }) + .forEach((code) => { + const meta = LOCALES[code].meta || {}; + const opt = document.createElement("option"); + opt.value = code; + opt.textContent = (meta.flag ? meta.flag + " " : "") + (meta.language_name || code); + sel.appendChild(opt); + }); + } + + // Apply a UI language: persist it (same localStorage key as the main client, + // so the choice carries across pages), flip direction, and re-localize the + // chrome, column headers, rows, and status message. + function applyLang(lang) { + currentLang = BOARD_UI[lang] ? lang : FALLBACK_LANG; + try { window.localStorage.setItem(UI_LANG_STORAGE_KEY, currentLang); } catch (_) {} + document.documentElement.lang = currentLang; + document.documentElement.dir = + (LOCALES[currentLang] && LOCALES[currentLang].meta && LOCALES[currentLang].meta.direction) || "ltr"; + document.title = "VoxVera — " + t("title"); + setText("board-title", t("title")); + setText("board-subtitle", t("subtitle")); + setText("board-back", t("back")); + const sel = document.getElementById("board-lang"); + if (sel) sel.value = currentLang; + setStatus(statusKey); + renderHead(); + renderBody(); + } + + async function init() { + populateLangSelect(); + const sel = document.getElementById("board-lang"); + if (sel) sel.addEventListener("change", () => applyLang(sel.value || FALLBACK_LANG)); + applyLang(currentLang); + + if (!window.NostrTools) { + setStatus("error"); + return; + } + + const { events, anyOpen } = await queryRelays(DEFAULT_RELAYS, 6000); + rows = parseFlyers(events); + if (!rows.length) { + setStatus(anyOpen ? "empty" : "error"); + return; + } + setStatus(null); + renderBody(); + } + + document.addEventListener("DOMContentLoaded", init); +})(); diff --git a/site/index.html b/site/index.html index f0a7087..ca05058 100644 --- a/site/index.html +++ b/site/index.html @@ -167,6 +167,7 @@
+ diff --git a/site/nostr-client.css b/site/nostr-client.css index 7cc768d..a552f66 100644 --- a/site/nostr-client.css +++ b/site/nostr-client.css @@ -641,6 +641,18 @@ hr { margin-top: 0; } +/* Screen-only link to the bulletin board, shown below the flyer preview + (outside the printed sheet). Hidden in print (see @media print). */ +.preview-band > .board-link { + text-align: center; + font-size: 13px; + margin: 12px 0 0; +} + +.board-link a { + color: var(--accent); +} + body.mode-viewer { background: #fff; } @@ -916,6 +928,7 @@ body.mode-viewer #viewer-panel.drawer-open .result-panel { body > .viewer-floating, .viewer-floating, .viewer-floating *, + .board-link, #open-viewer-controls, #viewer-print, #viewer-editor, @@ -963,3 +976,73 @@ body.mode-viewer #viewer-panel.drawer-open .result-panel { border: 0; } } + +/* ---- Bulletin board page (board.html) ---- */ +.board-main { + max-width: 1000px; + margin: 0 auto; + padding: 24px 20px 48px; +} + +.board-back { + color: var(--accent); + text-decoration: none; + font-weight: 700; +} + +#board-status { + color: var(--muted); + margin: 8px 0 16px; +} + +.board-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} + +.board-table th, +.board-table td { + text-align: left; + padding: 8px 10px; + border-bottom: 1px solid var(--line); + vertical-align: top; +} + +.board-table thead th { + position: sticky; + top: 0; + background: var(--soft); + cursor: pointer; + user-select: none; + white-space: nowrap; +} + +.board-table thead th .sort-indicator { + color: var(--muted); + font-size: 11px; +} + +.board-table tbody tr:hover { + background: #faf7f7; +} + +.board-table .board-title { + font-weight: 700; + overflow-wrap: anywhere; +} + +.board-table .board-npub { + font-family: "Courier New", monospace; + font-size: 12px; + white-space: nowrap; +} + +.board-table .board-date { + white-space: nowrap; + color: var(--muted); +} + +.board-table a { + color: var(--accent); +} diff --git a/site/nostr-client.js b/site/nostr-client.js index 3187dfe..80a68d6 100644 --- a/site/nostr-client.js +++ b/site/nostr-client.js @@ -198,6 +198,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui loading_content: "جارٍ تحميل المحتوى...", load_failed: "تعذّر تحميل هذا الملصق.", paper_size: "حجم الورق", + bulletin_board: "لوحة الإعلانات", paper_letter: "US Letter (8.5 × 11 بوصة)", paper_a4: "A4 (210 × 297 مم)", config_copied: "تم نسخ الإعداد.", @@ -255,6 +256,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui loading_content: "Inhalt wird geladen...", load_failed: "Dieses Plakat konnte nicht geladen werden.", paper_size: "Papierformat", + bulletin_board: "Schwarzes Brett", paper_letter: "US Letter (8,5 × 11 Zoll)", paper_a4: "A4 (210 × 297 mm)", config_copied: "Konfiguration kopiert.", @@ -312,6 +314,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui loading_content: "Loading content...", load_failed: "Could not load this flyer.", paper_size: "Paper size", + bulletin_board: "Bulletin board", paper_letter: "US Letter (8.5 × 11 in)", paper_a4: "A4 (210 × 297 mm)", config_copied: "Config copied.", @@ -370,6 +373,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui loading_content: "Cargando contenido...", load_failed: "No se pudo cargar este cartel.", paper_size: "Tamaño de papel", + bulletin_board: "Tablón de anuncios", paper_letter: "US Letter (8,5 × 11 in)", paper_a4: "A4 (210 × 297 mm)", config_copied: "Configuración copiada.", @@ -427,6 +431,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui loading_content: "در حال بارگذاری محتوا...", load_failed: "بارگذاری این پوستر ممکن نشد.", paper_size: "اندازه کاغذ", + bulletin_board: "تابلوی اعلانات", paper_letter: "US Letter (8.5 × 11 اینچ)", paper_a4: "A4 (210 × 297 میلی‌متر)", config_copied: "پیکربندی کپی شد.", @@ -484,6 +489,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui loading_content: "Chargement du contenu...", load_failed: "Impossible de charger cette affiche.", paper_size: "Format du papier", + bulletin_board: "Tableau d'affichage", paper_letter: "US Letter (8,5 × 11 po)", paper_a4: "A4 (210 × 297 mm)", config_copied: "Configuration copiee.", @@ -541,6 +547,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui loading_content: "טוען תוכן...", load_failed: "לא ניתן לטעון כרזה זו.", paper_size: "גודל נייר", + bulletin_board: "לוח מודעות", paper_letter: "US Letter (8.5 × 11 אינץ׳)", paper_a4: "A4 (210 × 297 מ״מ)", config_copied: "התצורה הועתקה.", @@ -598,6 +605,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui loading_content: "सामग्री लोड हो रही है...", load_failed: "यह पोस्टर लोड नहीं हो सका।", paper_size: "कागज़ का आकार", + bulletin_board: "बुलेटिन बोर्ड", paper_letter: "US Letter (8.5 × 11 इंच)", paper_a4: "A4 (210 × 297 मिमी)", config_copied: "कॉन्फिग कॉपी हुआ.", @@ -655,6 +663,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui loading_content: "コンテンツを読み込んでいます...", load_failed: "このちらしを読み込めませんでした。", paper_size: "用紙サイズ", + bulletin_board: "掲示板", paper_letter: "US レター (8.5 × 11 インチ)", paper_a4: "A4 (210 × 297 mm)", config_copied: "設定をコピーしました。", @@ -712,6 +721,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui loading_content: "Carregando conteúdo...", load_failed: "Não foi possível carregar este panfleto.", paper_size: "Tamanho do papel", + bulletin_board: "Quadro de avisos", paper_letter: "US Letter (8,5 × 11 pol)", paper_a4: "A4 (210 × 297 mm)", config_copied: "Configuracao copiada.", @@ -769,6 +779,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui loading_content: "Загрузка содержимого...", load_failed: "Не удалось загрузить эту листовку.", paper_size: "Размер бумаги", + bulletin_board: "Доска объявлений", paper_letter: "US Letter (8,5 × 11 дюйма)", paper_a4: "A4 (210 × 297 мм)", config_copied: "Конфигурация скопирована.", @@ -826,6 +837,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui loading_content: "Inapakia maudhui...", load_failed: "Imeshindwa kupakia bango hili.", paper_size: "Ukubwa wa karatasi", + bulletin_board: "Ubao wa matangazo", paper_letter: "US Letter (8.5 × 11 inchi)", paper_a4: "A4 (210 × 297 mm)", config_copied: "Usanidi umenakiliwa.", @@ -883,6 +895,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui loading_content: "İçerik yükleniyor...", load_failed: "Bu el ilanı yüklenemedi.", paper_size: "Kağıt boyutu", + bulletin_board: "İlan panosu", paper_letter: "US Letter (8,5 × 11 inç)", paper_a4: "A4 (210 × 297 mm)", config_copied: "Yapılandırma kopyalandı.", @@ -940,6 +953,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui loading_content: "正在加载内容...", load_failed: "无法加载此传单。", paper_size: "纸张大小", + bulletin_board: "公告栏", paper_letter: "US Letter (8.5 × 11 英寸)", paper_a4: "A4 (210 × 297 毫米)", config_copied: "配置已复制。",