Add bulletin board page listing recent flyers from the relays

A small screen-only link below the flyer preview (outside the printable
sheet, hidden in print) opens board.html: a spreadsheet-style list of the
most recent VoxVera flyers found on the default relays.

- Columns: title, link (the poster URL shown as "link"), posting npub,
  language, and posted date; every column is sortable (default date
  descending). Replaceable events are de-duplicated per author+d identifier,
  keeping the latest.
- board.js is independent of nostr-client.js (which exports nothing): its UI
  strings live in BOARD_UI for all 14 languages, and it reuses the vendored
  nostr-tools and locales.js (language names + RTL direction).
- The board has its own language selector that reads/writes the same
  voxvera_nostr_lang localStorage key as the main client, so the choice
  carries across pages; switching it re-localizes the chrome, headers,
  language labels, and date formatting and flips RTL.
- Untrusted relay content is escaped, and link URLs are rejected unless
  http(s) so javascript:/data: never render as a link.

Docs updated (README, spec, AGENTS). Playwright: add tests for the
out-of-sheet print-hidden link, the localized sortable columns, and the
board language selector (incl. RTL + persistence). 72 tests pass across
Chromium, Firefox, WebKit, and Android/iOS mobile emulations.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-09 16:28:32 -04:00
1 parent 127f9c3134
commit 98086a0ad4
9 files changed
+510

No files matched your search

+1
View File
@@ -5,6 +5,7 @@
## Current Architecture ## Current Architecture
- Active frontend: `site/` (`index.html`, `nostr-client.js`, `nostr-client.css`, `locales.js`) - 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` - Static deployment root: `site`
- Browser dependencies: vendored under `site/vendor/` - 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 - Legacy path: `site/nostr/index.html` is a redirect stub forwarding old `/nostr/` poster URLs and QR codes to the root
+2
View File
@@ -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). - **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. - **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. - **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/`. - **Self-contained.** No build step and no CDNs — Nostr and QR libraries are vendored under `site/vendor/`.
## What Is In `main` ## What Is In `main`
- `site/index.html`, `site/nostr-client.js`, `site/nostr-client.css`, `site/locales.js`: static editor/viewer client - `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/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/nostr/index.html`: redirect stub so legacy `/nostr/` poster URLs and QR codes still resolve
- `site/CNAME`: custom-domain target - `site/CNAME`: custom-domain target
+28
View File
@@ -26,6 +26,8 @@ site/
nostr-client.css nostr-client.css
nostr-client.js nostr-client.js
locales.js locales.js
board.html (bulletin board page)
board.js
CNAME CNAME
vendor/ vendor/
nostr-tools/ nostr-tools/
@@ -152,6 +154,32 @@ including the EU, Russia, China, Japan, and the Middle East).
Field capacity therefore varies by paper size; see Field capacity therefore varies by paper size; see
[`flyer-field-limits.md`](flyer-field-limits.md). [`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 ## Localization
Flyer defaults come from `site/locales.js`. Flyer defaults come from `site/locales.js`.
+44
View File
@@ -146,4 +146,48 @@ test.describe("VoxVera static client", () => {
// Stored override beats the en-US region default. // Stored override beats the en-US region default.
await expect(page.locator("html")).toHaveClass(/paper-a4/); 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("لوحة الإعلانات");
});
}); });
+31
View File
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VoxVera — Bulletin Board</title>
<link rel="stylesheet" href="nostr-client.css">
</head>
<body class="board-page">
<header class="topbar">
<div>
<h1 id="board-title">Bulletin Board</h1>
<p id="board-subtitle">Most recent flyers published to the relays.</p>
</div>
<div class="topbar-controls">
<a id="board-back" class="board-back" href="./">Back to VoxVera</a>
<select id="board-lang" aria-label="Language"></select>
</div>
</header>
<main class="board-main">
<p id="board-status">Loading…</p>
<table class="board-table" id="board-table">
<thead><tr id="board-head"></tr></thead>
<tbody id="board-rows"></tbody>
</table>
</main>
<script src="locales.js"></script>
<script src="vendor/nostr-tools/nostr.bundle.js"></script>
<script src="board.js"></script>
</body>
</html>
+306
View File
@@ -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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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) => `<span class="board-title">${escapeHtml(r.title || "—")}</span>`,
cmp: (a, b) => (a.title || "").localeCompare(b.title || "") },
{ key: "link", label: () => t("col_link"),
cell: (r) => isSafeUrl(r.link)
? `<a href="${escapeHtml(r.link)}" target="_blank" rel="noopener noreferrer">${escapeHtml(t("link_text"))}</a>`
: "—",
cmp: (a, b) => (a.link || "").localeCompare(b.link || "") },
{ key: "author", label: () => t("col_author"),
cell: (r) => `<span class="board-npub" title="${escapeHtml(r.npub)}">${escapeHtml(shortNpub(r.npub))}</span>`,
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) => `<span class="board-date">${escapeHtml(fmtDate(r.created_at))}</span>`,
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 `<th data-key="${c.key}">${escapeHtml(c.label())}<span class="sort-indicator">${ind}</span></th>`;
}).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) => `<tr>${COLS.map((c) => `<td>${c.cell(r)}</td>`).join("")}</tr>`)
.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);
})();
+1
View File
@@ -167,6 +167,7 @@
<button type="button" id="print-preview">Print preview</button> <button type="button" id="print-preview">Print preview</button>
</div> </div>
<div id="flyer-preview" class="flyer-preview"></div> <div id="flyer-preview" class="flyer-preview"></div>
<p class="board-link"><a href="board.html" data-i18n-key="bulletin_board">Bulletin board</a></p>
</section> </section>
</main> </main>
+83
View File
@@ -641,6 +641,18 @@ hr {
margin-top: 0; 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 { body.mode-viewer {
background: #fff; background: #fff;
} }
@@ -916,6 +928,7 @@ body.mode-viewer #viewer-panel.drawer-open .result-panel {
body > .viewer-floating, body > .viewer-floating,
.viewer-floating, .viewer-floating,
.viewer-floating *, .viewer-floating *,
.board-link,
#open-viewer-controls, #open-viewer-controls,
#viewer-print, #viewer-print,
#viewer-editor, #viewer-editor,
@@ -963,3 +976,73 @@ body.mode-viewer #viewer-panel.drawer-open .result-panel {
border: 0; 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);
}
+14
View File
@@ -198,6 +198,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
loading_content: "جارٍ تحميل المحتوى...", loading_content: "جارٍ تحميل المحتوى...",
load_failed: "تعذّر تحميل هذا الملصق.", load_failed: "تعذّر تحميل هذا الملصق.",
paper_size: "حجم الورق", paper_size: "حجم الورق",
bulletin_board: "لوحة الإعلانات",
paper_letter: "US Letter (8.5 × 11 بوصة)", paper_letter: "US Letter (8.5 × 11 بوصة)",
paper_a4: "A4 (210 × 297 مم)", paper_a4: "A4 (210 × 297 مم)",
config_copied: "تم نسخ الإعداد.", 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...", loading_content: "Inhalt wird geladen...",
load_failed: "Dieses Plakat konnte nicht geladen werden.", load_failed: "Dieses Plakat konnte nicht geladen werden.",
paper_size: "Papierformat", paper_size: "Papierformat",
bulletin_board: "Schwarzes Brett",
paper_letter: "US Letter (8,5 × 11 Zoll)", paper_letter: "US Letter (8,5 × 11 Zoll)",
paper_a4: "A4 (210 × 297 mm)", paper_a4: "A4 (210 × 297 mm)",
config_copied: "Konfiguration kopiert.", 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...", loading_content: "Loading content...",
load_failed: "Could not load this flyer.", load_failed: "Could not load this flyer.",
paper_size: "Paper size", paper_size: "Paper size",
bulletin_board: "Bulletin board",
paper_letter: "US Letter (8.5 × 11 in)", paper_letter: "US Letter (8.5 × 11 in)",
paper_a4: "A4 (210 × 297 mm)", paper_a4: "A4 (210 × 297 mm)",
config_copied: "Config copied.", 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...", loading_content: "Cargando contenido...",
load_failed: "No se pudo cargar este cartel.", load_failed: "No se pudo cargar este cartel.",
paper_size: "Tamaño de papel", paper_size: "Tamaño de papel",
bulletin_board: "Tablón de anuncios",
paper_letter: "US Letter (8,5 × 11 in)", paper_letter: "US Letter (8,5 × 11 in)",
paper_a4: "A4 (210 × 297 mm)", paper_a4: "A4 (210 × 297 mm)",
config_copied: "Configuración copiada.", 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: "در حال بارگذاری محتوا...", loading_content: "در حال بارگذاری محتوا...",
load_failed: "بارگذاری این پوستر ممکن نشد.", load_failed: "بارگذاری این پوستر ممکن نشد.",
paper_size: "اندازه کاغذ", paper_size: "اندازه کاغذ",
bulletin_board: "تابلوی اعلانات",
paper_letter: "US Letter (8.5 × 11 اینچ)", paper_letter: "US Letter (8.5 × 11 اینچ)",
paper_a4: "A4 (210 × 297 میلی‌متر)", paper_a4: "A4 (210 × 297 میلی‌متر)",
config_copied: "پیکربندی کپی شد.", 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...", loading_content: "Chargement du contenu...",
load_failed: "Impossible de charger cette affiche.", load_failed: "Impossible de charger cette affiche.",
paper_size: "Format du papier", paper_size: "Format du papier",
bulletin_board: "Tableau d'affichage",
paper_letter: "US Letter (8,5 × 11 po)", paper_letter: "US Letter (8,5 × 11 po)",
paper_a4: "A4 (210 × 297 mm)", paper_a4: "A4 (210 × 297 mm)",
config_copied: "Configuration copiee.", config_copied: "Configuration copiee.",
@@ -541,6 +547,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
loading_content: "טוען תוכן...", loading_content: "טוען תוכן...",
load_failed: "לא ניתן לטעון כרזה זו.", load_failed: "לא ניתן לטעון כרזה זו.",
paper_size: "גודל נייר", paper_size: "גודל נייר",
bulletin_board: "לוח מודעות",
paper_letter: "US Letter (8.5 × 11 אינץ׳)", paper_letter: "US Letter (8.5 × 11 אינץ׳)",
paper_a4: "A4 (210 × 297 מ״מ)", paper_a4: "A4 (210 × 297 מ״מ)",
config_copied: "התצורה הועתקה.", config_copied: "התצורה הועתקה.",
@@ -598,6 +605,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
loading_content: "सामग्री लोड हो रही है...", loading_content: "सामग्री लोड हो रही है...",
load_failed: "यह पोस्टर लोड नहीं हो सका।", load_failed: "यह पोस्टर लोड नहीं हो सका।",
paper_size: "कागज़ का आकार", paper_size: "कागज़ का आकार",
bulletin_board: "बुलेटिन बोर्ड",
paper_letter: "US Letter (8.5 × 11 इंच)", paper_letter: "US Letter (8.5 × 11 इंच)",
paper_a4: "A4 (210 × 297 मिमी)", paper_a4: "A4 (210 × 297 मिमी)",
config_copied: "कॉन्फिग कॉपी हुआ.", config_copied: "कॉन्फिग कॉपी हुआ.",
@@ -655,6 +663,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
loading_content: "コンテンツを読み込んでいます...", loading_content: "コンテンツを読み込んでいます...",
load_failed: "このちらしを読み込めませんでした。", load_failed: "このちらしを読み込めませんでした。",
paper_size: "用紙サイズ", paper_size: "用紙サイズ",
bulletin_board: "掲示板",
paper_letter: "US レター (8.5 × 11 インチ)", paper_letter: "US レター (8.5 × 11 インチ)",
paper_a4: "A4 (210 × 297 mm)", paper_a4: "A4 (210 × 297 mm)",
config_copied: "設定をコピーしました。", 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...", loading_content: "Carregando conteúdo...",
load_failed: "Não foi possível carregar este panfleto.", load_failed: "Não foi possível carregar este panfleto.",
paper_size: "Tamanho do papel", paper_size: "Tamanho do papel",
bulletin_board: "Quadro de avisos",
paper_letter: "US Letter (8,5 × 11 pol)", paper_letter: "US Letter (8,5 × 11 pol)",
paper_a4: "A4 (210 × 297 mm)", paper_a4: "A4 (210 × 297 mm)",
config_copied: "Configuracao copiada.", config_copied: "Configuracao copiada.",
@@ -769,6 +779,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
loading_content: "Загрузка содержимого...", loading_content: "Загрузка содержимого...",
load_failed: "Не удалось загрузить эту листовку.", load_failed: "Не удалось загрузить эту листовку.",
paper_size: "Размер бумаги", paper_size: "Размер бумаги",
bulletin_board: "Доска объявлений",
paper_letter: "US Letter (8,5 × 11 дюйма)", paper_letter: "US Letter (8,5 × 11 дюйма)",
paper_a4: "A4 (210 × 297 мм)", paper_a4: "A4 (210 × 297 мм)",
config_copied: "Конфигурация скопирована.", config_copied: "Конфигурация скопирована.",
@@ -826,6 +837,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
loading_content: "Inapakia maudhui...", loading_content: "Inapakia maudhui...",
load_failed: "Imeshindwa kupakia bango hili.", load_failed: "Imeshindwa kupakia bango hili.",
paper_size: "Ukubwa wa karatasi", paper_size: "Ukubwa wa karatasi",
bulletin_board: "Ubao wa matangazo",
paper_letter: "US Letter (8.5 × 11 inchi)", paper_letter: "US Letter (8.5 × 11 inchi)",
paper_a4: "A4 (210 × 297 mm)", paper_a4: "A4 (210 × 297 mm)",
config_copied: "Usanidi umenakiliwa.", 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...", loading_content: "İçerik yükleniyor...",
load_failed: "Bu el ilanı yüklenemedi.", load_failed: "Bu el ilanı yüklenemedi.",
paper_size: "Kağıt boyutu", paper_size: "Kağıt boyutu",
bulletin_board: "İlan panosu",
paper_letter: "US Letter (8,5 × 11 inç)", paper_letter: "US Letter (8,5 × 11 inç)",
paper_a4: "A4 (210 × 297 mm)", paper_a4: "A4 (210 × 297 mm)",
config_copied: "Yapılandırma kopyalandı.", 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: "正在加载内容...", loading_content: "正在加载内容...",
load_failed: "无法加载此传单。", load_failed: "无法加载此传单。",
paper_size: "纸张大小", paper_size: "纸张大小",
bulletin_board: "公告栏",
paper_letter: "US Letter (8.5 × 11 英寸)", paper_letter: "US Letter (8.5 × 11 英寸)",
paper_a4: "A4 (210 × 297 毫米)", paper_a4: "A4 (210 × 297 毫米)",
config_copied: "配置已复制。", config_copied: "配置已复制。",