mirror of
https://github.com/PR0M3TH3AN/VoxVera.git
synced 2026-10-27 09:40:40 +00:00
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:
1 parent
127f9c3134
commit
98086a0ad4
9 files changed
+510
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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("لوحة الإعلانات");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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
@@ -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) => `<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);
|
||||||
|
})();
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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: "配置已复制。",
|
||||||
|
|||||||
Reference in new issue
Block a user