Files
VoxVera/site/nostr-client.js
T
thePR0M3TH3ANandClaude Opus 4.8 29ed45533d Add localized Safety & privacy page + static link-preview meta
New standalone /safety page (safety.html / safety.js), localized across
all 14 languages, in plain language: what VoxVera protects, what it does
NOT (relays see your IP; the anonymous key is stored unencrypted; a short
PIN won't stop a targeted attacker; published events are public and
permanent; nsec-paste risk; reports are public; the host/relays still see
requests), and operational tips (Tor/VPN, disposable keys, signer apps).
Linked from the editor and the board. Aimed at the activist audience the
aesthetic invites — being honest about limits matters more than features.

Also adds static site-wide Open Graph / Twitter-card meta to the home,
board, and safety pages so a shared voxvera.org link renders a real
title/description instead of nothing. Per-flyer previews remain a
deliberate non-goal: the naddr lives in the URL fragment (invisible to
servers) and per-flyer meta would require server-side rendering, which is
out of scope for a purely static site.

i18n guard extended to SAFETY_UI (keys + equal bullet-list lengths per
language). New cross-engine test covers the page's sections, localization,
RTL, and the editor/board links. Full suite 354 passing.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-11 14:53:48 -04:00

3986 lines
179 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
"use strict";
const DEFAULT_RELAYS = [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.primal.net"
];
const ANON_SECRET_STORAGE_KEY = "voxvera_nostr_anon_secret_hex";
// Publishing identity ("anon" | "nip07" | "nsec") and, for an nsec the user
// chose to remember, its PIN-encrypted secret. The plaintext secret is never
// stored for nsec — only this AES-GCM blob, decrypted in memory on unlock.
const IDENTITY_MODE_STORAGE_KEY = "voxvera_identity_mode";
const IDENTITY_ENC_STORAGE_KEY = "voxvera_identity_nsec_enc";
// Shared with the bulletin board: mirror the editor's active pubkey here so a
// deliberate identity choice (connect / import / unlock / generate) carries
// over and the board reflects the same identity (and "My flyers" works).
const BOARD_CONNECTED_PUBKEY_KEY = "voxvera_connected_pubkey";
// Wrong-PIN lockout: after a few bad PINs, refuse unlocking for a cooldown.
// A deterrent against casual on-device guessing (a leaked blob is still
// offline-brute-forceable — see the PIN caveat in docs/roadmap.md). The
// lockout deadline is persisted so a reload can't reset the attempt budget.
const UNLOCK_LOCK_STORAGE_KEY = "voxvera_identity_unlock_until";
const MAX_UNLOCK_FAILS = 3;
const UNLOCK_COOLDOWN_MS = 30000;
const IDENTITY_NPUB_STORAGE_KEY = "voxvera_identity_npub";
const PBKDF2_ITERATIONS = 600000;
// NIP-46 remote signer (bunker) kind + timeouts. The connect/sign waits are
// long because they require a human to approve the request in the signer app.
const NIP46_KIND = 24133;
const NIP46_RELAY_TIMEOUT_MS = 12000;
const NIP46_CONNECT_TIMEOUT_MS = 90000;
const NIP46_SIGN_TIMEOUT_MS = 90000;
const UI_LANG_STORAGE_KEY = "voxvera_nostr_lang";
const LOCALES = window.VoxVeraLocales || {};
const FALLBACK_LANG = "en";
const LANGUAGE_SELECT_IDS = ["topbar-lang", "field-lang", "viewer-lang"];
const PAPER_SELECT_IDS = ["topbar-paper", "viewer-paper"];
const PAPER_STORAGE_KEY = "voxvera_paper";
// Print paper sizes. `css` is the CSS @page size keyword. The actual sheet
// dimensions live in nostr-client.css (--sheet-w / --sheet-h per paper class).
const PAPER_SIZES = {
letter: { css: "Letter" },
a4: { css: "A4" }
};
const DEFAULT_PAPER = "letter";
// ISO 3166-1 alpha-2 regions whose default print paper is US Letter.
// Everywhere else defaults to A4 (ISO 216), which is the global standard.
const LETTER_REGIONS = new Set([
"US", "CA", "MX", "CL", "CO", "CR", "GT", "DO", "PH", "VE", "PA", "NI", "SV", "HN", "BO", "EC", "PR"
]);
const EVENT_KIND = 30078;
// Per-script letter-spacing policy for flyer text. `letter-spacing` is not
// script-neutral, so the flyer tightens tracking only where it is safe:
// "full" (Latin, Cyrillic, Hebrew): reduced positive tracking + slight
// negative on body/url to reclaim space per line.
// "cjk" (Japanese, Chinese): tracking clamped to 0 — full-width glyphs
// must not overlap, so never negative.
// "none" (Arabic, Persian, Hindi): letter-spacing OFF. These are cursive
// or complex scripts where any tracking breaks the letter joins or
// detaches combining marks; neutralizing it also fixes rendering.
// The values themselves live in nostr-client.css, keyed by the ls-* class.
const LETTER_SPACING_POLICY = {
ar: "none", fa: "none", hi: "none",
ja: "cjk", zh: "cjk"
};
function trackingPolicy(lang) {
return LETTER_SPACING_POLICY[supportedLang(lang)] || "full";
}
const FIELD_LIMITS = {
folder_name: 64,
name: 120,
title: 80,
subtitle: 120,
headline: 160,
content: 10000,
url_message: 240,
url: 2048,
tear_off_link: 2048,
footer_message: 240
};
const CONFIG_DEFAULTS = {
name: "Vox Vera Printable Flyers",
folder_name: "voxvera",
lang: "en",
title: "TOP SECRET",
subtitle: "DO ~~NOT~~ DISTRIBUTE",
headline: "OPERATION VOX VERA",
content: `Break free from censorship with VoxVera. A Nostr-powered guerrilla marketing and message-spreading tool. Whether online or in the physical world, it helps you publish flyer content to decentralized relays and spread your ideas boldly without relying on a central server.
Use memetic power to share your ideas in your school, workplace, online communities, or even globally. VoxVera ensures your message resonates far and wide, with tear-off sections featuring unique URLs and QR codes for easy reprinting.
Resilience comes from Nostr. Flyer source events can be mirrored across relays, while any static or offline client can fetch, verify, and render the poster again.
Join us in a revolution that values truth and transparency. Together, we can build a network of informed citizens who are unafraid to speak out.`,
url_message: "Follow this link to learn more.",
url: "https://voxvera.org/",
tear_off_link: "",
footer_message: "0110010 0101011 0110010 0111101 0110100",
attachment_path: "",
attachment_filename: ""
};
const UI_LABELS = {
"label-relays": "labels.relays",
"label-title": "labels.title",
"label-subtitle": "labels.subtitle",
"label-headline": "labels.headline",
"label-content": "labels.content",
"label-url-message": "labels.url_message",
"label-poster-url": "labels.poster_url",
"label-footer-message": "labels.footer_message",
"label-viewer-relays": "labels.relays"
};
const NOSTR_TEXT_LABELS = {
"label-flyer-name": "flyer_name",
"label-language": "language",
"label-page-title": "page_title",
"label-event-id": "event_id"
};
const BUTTON_LABELS = {
"preview-editor": "preview",
"import-design": "import_design",
"export-design": "export_design",
"publish-event": "publish_event",
"copy-viewer-config": "copy_config",
"print-preview": "print_preview",
"open-viewer-controls": "load_event",
"viewer-print": "print",
"viewer-editor": "editor",
"close-viewer-controls": "back",
"generate-anon-identity": "generate_npub",
"connect-nip07": "connect_extension",
"use-nsec-toggle": "use_nsec",
"use-nip46-toggle": "use_nip46",
"nsec-submit": "use_key",
"nip46-submit": "connect_remote",
"unlock-key": "unlock",
"forget-key": "forget_key",
"viewer-fetch-render": "fetch_render",
"editor-tab": "editor",
"viewer-tab": "viewer"
};
const HEADING_LABELS = {
"editor-heading": "editor",
"viewer-heading": "viewer",
"published-event-heading": "published_event",
"normalized-config-heading": "normalized_config",
"preview-heading": "preview"
};
const STATIC_TEXT_LABELS = {
"client-title": "client_title",
"client-description": "client_description",
"result-event-id-label": "event_id",
"result-author-npub-label": "author_npub",
"result-naddr-label": "nostr_address",
"result-poster-url-label": "poster_url",
"result-status-label": "status"
};
const EMPTY_OUTPUT_LABELS = {
"event-id-output": "not_published",
"author-npub-output": "not_generated",
"naddr-output": "not_generated",
"poster-url-output": "not_generated"
};
const TEXT_FIELD_IDS = [
"field-folder-name",
"field-name",
"field-title",
"field-subtitle",
"field-headline",
"field-content",
"field-url-message",
"field-url",
"field-footer-message"
];
const DISPLAY_FIT_TARGETS = {
"field-title": ".content h1:nth-of-type(1)",
"field-subtitle": ".distribute",
"field-headline": ".content h1:nth-of-type(2)",
"field-content": ".message",
"field-url-message": ".url-message",
"field-url": ".qr-code-url a",
"field-footer-message": ".footer .binary"
};
const NOSTR_UI = {
ar: {
back: "رجوع",
copy_config: "نسخ JSON للإعداد",
editor: "المحرر",
event_id: "معرّف الحدث",
export_event: "تصدير JSON الحدث",
fetch_render: "جلب وعرض",
flyer_name: "اسم المنشور",
generate_npub: "إنشاء npub مجهول",
language: "اللغة",
load_event: "تحميل الحدث",
normalized_config: "الإعداد الموحد",
open_poster: "انقر لفتح هذا الملصق.",
page_title: "عنوان الصفحة",
preview: "معاينة",
print: "اطبع المنشور",
print_preview: "اطبع المعاينة",
safety_link: "الأمان والخصوصية",
publish_event: "توقيع ونشر",
published_event: "الحدث المنشور",
share_reprint: "شارك وأعد الطباعة.",
tab_link: "افتح وأعد طباعة هذا الملصق",
tab_event_id: "أعد الفتح على VoxVera.org — معرّف الحدث",
built_with: "بني بواسطة",
viewer: "العارض",
client_title: "عميل VoxVera Nostr",
client_description: "محرر وعارض ثابت لأحداث مصدر المنشور اللامركزية.",
author_npub: "npub المؤلف",
nostr_address: "عنوان Nostr",
poster_url: "رابط الملصق",
status: "الحالة",
copy: "نسخ",
copied: "تم النسخ",
not_published: "لم ينشر",
not_generated: "لم ينشأ",
idle: "خامل",
checking_signer: "فحص الموقّع",
checking_identity: "فحص الهوية",
anon_signing_nip07: "التوقيع المجهول هو الافتراضي؛ تم اكتشاف NIP-07",
anon_signing_ready: "التوقيع المجهول متاح؛ لا يلزم تسجيل دخول NIP-07",
connect_extension: "ربط الإضافة",
use_nsec: "استخدام مفتاح خاص (nsec)",
use_key: "استخدام هذا المفتاح",
remember_pin: "تذكُّر على هذا الجهاز (رمز PIN)",
pin_prompt: "رمز PIN (4 أرقام أو أكثر)",
unlock: "فتح القفل",
forget_key: "نسيان",
invalid_nsec: "لا يبدو هذا مفتاح nsec صالحًا.",
pin_short: "يجب أن يتكون رمز PIN من 4 أرقام على الأقل.",
pin_wrong: "رمز PIN غير صحيح. حاول مرة أخرى.",
nip07_missing: "لم يتم العثور على إضافة NIP-07.",
identity_locked: "الهوية المحفوظة مقفلة. أدخل رمز PIN لفتحها.",
locked: "مقفل",
signer_anon: "النشر بشكل مجهول",
signer_nip07: "النشر بهوية الإضافة الخاصة بك",
signer_nsec: "النشر بمفتاحك المستورد",
republish: "إعادة النشر إلى المرحلات",
delete_flyer: "حذف الملصق",
delete_title: "حذف هذا الملصق؟",
delete_message: "يطلب هذا من المرحلات حذفه ويستبدله بعلامة حذف حتى يخفيه VoxVera. لا يمكن ضمان الحذف عبر كل مرحل.",
delete_confirm: "حذف",
cancel: "إلغاء",
flyer_removed: "تمت إزالة هذا الملصق.",
deleting: "جارٍ الحذف…",
deleted_done: "تم طلب الحذف.",
republished_done: "تمت إعادة النشر إلى المرحلات.",
not_author: "يمكنك فقط حذف أو إعادة نشر الملصقات التي نشرتها.",
field_too_long: "هذا الحقل طويل جدًا على الملصق القابل للطباعة:",
relays_accepted: "المرحلات التي قبلت",
too_many_attempts: "محاولات كثيرة جدًا — انتظر لحظة وحاول مرة أخرى.",
use_nip46: "موقّع عن بُعد (NIP-46)",
connect_remote: "اتصال",
signer_nip46: "النشر عبر موقّع عن بُعد",
nip46_hint: "الصق رابط bunker:// من تطبيق التوقيع لديك (مثل nsec.app أو Amber).",
nip46_connecting: "جارٍ الاتصال — وافق على الطلب في تطبيق التوقيع…",
nip46_connected: "تم الاتصال بالموقّع عن بُعد.",
nip46_bad_uri: "لا يبدو هذا رابط bunker://.",
nip46_timeout: "لم يستجب الموقّع عن بُعد. افتح التطبيق وحاول مرة أخرى.",
nip46_failed: "تعذّر الاتصال بالموقّع عن بُعد.",
import_design: "استيراد التصميم",
export_design: "تصدير التصميم",
import_confirm_title: "هل تريد استيراد هذا التصميم؟",
import_confirm_message: "سيحل هذا محل المنشور الموجود حاليًا في المحرر.",
import_button: "استيراد",
design_imported: "تم استيراد التصميم.",
design_exported: "تم تصدير التصميم.",
invalid_design_file: "هذا الملف ليس تصميم VoxVera صالحًا.",
no_anon_npub: "لا يوجد npub مجهول بعد",
anon_npub_ready: "npub مجهول جاهز",
npub_generated: "تم إنشاء npub مجهول:",
preview_updated: "تم تحديث المعاينة.",
signing_publishing: "جار التوقيع والنشر...",
fetching: "جار الجلب...",
fetched: "تم الجلب",
loading_content: "جارٍ تحميل المحتوى...",
load_failed: "تعذّر تحميل هذا الملصق.",
paper_size: "حجم الورق",
bulletin_board: "لوحة الإعلانات",
published_title: "تم نشر الملصق",
published_message: "انسخ هذا الرابط لرؤية ملصقك الجديد:",
close: "إغلاق",
paper_letter: "US Letter (8.5 × 11 بوصة)",
paper_a4: "A4 (210 × 297 مم)",
config_copied: "تم نسخ الإعداد.",
lookup_placeholder: "naddr أو معرف حدث أو note1 أو nevent1 أو رابط nostr:",
event_reference_required: "أدخل naddr أو معرف حدث أو note1 أو nevent1.",
relay_required: "أدخل رابط relay واحد على الأقل يبدأ بـ wss://.",
failed: "فشل",
timeout: "انتهت المهلة",
connection_error: "خطأ في الاتصال"
},
de: {
back: "Zurück",
copy_config: "Konfig-JSON kopieren",
editor: "Editor",
event_id: "Event-ID",
export_event: "Event-JSON exportieren",
fetch_render: "Abrufen und anzeigen",
flyer_name: "Flyername",
generate_npub: "Anonymes npub erzeugen",
language: "Sprache",
load_event: "Event laden",
normalized_config: "Normalisierte Konfiguration",
open_poster: "Klicken Sie, um dieses Plakat zu öffnen.",
page_title: "Seitentitel",
preview: "Vorschau",
print: "Flyer drucken",
print_preview: "Vorschau drucken",
safety_link: "Sicherheit & Privatsphäre",
publish_event: "Signieren und veröffentlichen",
published_event: "Veröffentlichtes Event",
share_reprint: "Teilen und neu drucken.",
tab_link: "Plakat öffnen & neu drucken",
tab_event_id: "Erneut öffnen auf VoxVera.org — Event-ID",
built_with: "Erstellt mit",
viewer: "Viewer",
client_title: "VoxVera Nostr-Client",
client_description: "Statischer Editor und Viewer für dezentrale Flyer-Quellereignisse.",
author_npub: "Autor-npub",
nostr_address: "Nostr-Adresse",
poster_url: "Poster-URL",
status: "Status",
copy: "Kopieren",
copied: "Kopiert",
not_published: "Nicht veröffentlicht",
not_generated: "Nicht erzeugt",
idle: "Bereit",
checking_signer: "Signer prüfen",
checking_identity: "Identität prüfen",
anon_signing_nip07: "Anonymes Signieren ist Standard; NIP-07 erkannt",
anon_signing_ready: "Anonymes Signieren verfügbar; kein NIP-07-Login nötig",
connect_extension: "Erweiterung verbinden",
use_nsec: "Privaten Schlüssel (nsec) verwenden",
use_key: "Diesen Schlüssel verwenden",
remember_pin: "Auf diesem Gerät merken (PIN)",
pin_prompt: "PIN (mind. 4 Ziffern)",
unlock: "Entsperren",
forget_key: "Verwerfen",
invalid_nsec: "Das sieht nicht nach einem gültigen nsec-Schlüssel aus.",
pin_short: "Die PIN muss mindestens 4 Ziffern haben.",
pin_wrong: "Falsche PIN. Bitte erneut versuchen.",
nip07_missing: "Keine NIP-07-Erweiterung gefunden.",
identity_locked: "Gespeicherte Identität ist gesperrt. Gib deine PIN ein, um sie zu entsperren.",
locked: "Gesperrt",
signer_anon: "Anonym veröffentlichen",
signer_nip07: "Veröffentlichung mit deiner Erweiterungs-Identität",
signer_nsec: "Veröffentlichung mit deinem importierten Schlüssel",
republish: "Erneut auf Relays veröffentlichen",
delete_flyer: "Plakat löschen",
delete_title: "Dieses Plakat löschen?",
delete_message: "Dies bittet die Relays, es zu löschen, und ersetzt es durch eine Löschmarkierung, sodass VoxVera es ausblendet. Eine Löschung über alle Relays hinweg kann nicht garantiert werden.",
delete_confirm: "Löschen",
cancel: "Abbrechen",
flyer_removed: "Dieses Plakat wurde entfernt.",
deleting: "Wird gelöscht…",
deleted_done: "Löschung angefordert.",
republished_done: "Erneut auf Relays veröffentlicht.",
not_author: "Du kannst nur Plakate löschen oder erneut veröffentlichen, die du veröffentlicht hast.",
field_too_long: "Dieses Feld ist zu lang für das druckbare Plakat:",
relays_accepted: "Relays akzeptiert",
too_many_attempts: "Zu viele Versuche – warte einen Moment und versuche es erneut.",
use_nip46: "Externer Signierer (NIP-46)",
connect_remote: "Verbinden",
signer_nip46: "Veröffentlichung über externen Signierer",
nip46_hint: "Füge einen bunker://-Link aus deiner Signier-App ein (z. B. nsec.app, Amber).",
nip46_connecting: "Verbinde – bestätige die Anfrage in deiner Signier-App…",
nip46_connected: "Externer Signierer verbunden.",
nip46_bad_uri: "Das sieht nicht nach einem bunker://-Link aus.",
nip46_timeout: "Der externe Signierer hat nicht geantwortet. Öffne die App und versuche es erneut.",
nip46_failed: "Verbindung zum externen Signierer fehlgeschlagen.",
import_design: "Design importieren",
export_design: "Design exportieren",
import_confirm_title: "Dieses Design importieren?",
import_confirm_message: "Dies ersetzt den aktuell im Editor befindlichen Flyer.",
import_button: "Importieren",
design_imported: "Design importiert.",
design_exported: "Design exportiert.",
invalid_design_file: "Diese Datei ist kein gültiges VoxVera-Design.",
no_anon_npub: "Noch kein anonymes npub",
anon_npub_ready: "Anonymes npub bereit",
npub_generated: "Anonymes npub erzeugt:",
preview_updated: "Vorschau aktualisiert.",
signing_publishing: "Signieren und veröffentlichen...",
fetching: "Abrufen...",
fetched: "Abgerufen",
loading_content: "Inhalt wird geladen...",
load_failed: "Dieses Plakat konnte nicht geladen werden.",
paper_size: "Papierformat",
bulletin_board: "Schwarzes Brett",
published_title: "Plakat veröffentlicht",
published_message: "Kopiere diese URL, um dein neues Plakat zu sehen:",
close: "Schließen",
paper_letter: "US Letter (8,5 × 11 Zoll)",
paper_a4: "A4 (210 × 297 mm)",
config_copied: "Konfiguration kopiert.",
lookup_placeholder: "naddr, Event-ID, note1, nevent1 oder nostr:-URL",
event_reference_required: "Geben Sie naddr, Event-ID, note1 oder nevent1 ein.",
relay_required: "Geben Sie mindestens ein wss://-Relay ein.",
failed: "fehlgeschlagen",
timeout: "Zeitüberschreitung",
connection_error: "Verbindungsfehler"
},
en: {
back: "Back",
copy_config: "Copy config JSON",
editor: "Editor",
event_id: "Event ID",
export_event: "Export event JSON",
fetch_render: "Fetch and render",
flyer_name: "Flyer Name",
generate_npub: "Generate anonymous npub",
language: "Language",
load_event: "Load Event",
normalized_config: "Normalized Config",
open_poster: "Click to open this poster.",
page_title: "Page title",
preview: "Preview",
print: "Print this page",
print_preview: "Print preview",
safety_link: "Safety & privacy",
publish_event: "Sign and publish",
published_event: "Published Event",
share_reprint: "Share and reprint.",
tab_link: "Open & reprint this flyer",
tab_event_id: "Reopen at VoxVera.org — Event ID",
built_with: "Built with",
viewer: "Viewer",
client_title: "VoxVera Nostr Client",
client_description: "Static editor and viewer for decentralized flyer source events.",
author_npub: "Author npub",
nostr_address: "Nostr address",
poster_url: "Poster URL",
status: "Status",
copy: "Copy",
copied: "Copied",
not_published: "Not published",
not_generated: "Not generated",
idle: "Idle",
checking_signer: "Checking signer",
checking_identity: "Checking identity",
anon_signing_nip07: "Anonymous signing is default; NIP-07 detected",
anon_signing_ready: "Anonymous signing available; no NIP-07 login required",
connect_extension: "Connect extension",
use_nsec: "Use a private key (nsec)",
use_key: "Use this key",
remember_pin: "Remember on this device (PIN)",
pin_prompt: "PIN (4+ digits)",
unlock: "Unlock",
forget_key: "Forget",
invalid_nsec: "That doesn't look like a valid nsec key.",
pin_short: "PIN must be at least 4 digits.",
pin_wrong: "Wrong PIN. Try again.",
nip07_missing: "No NIP-07 extension found.",
identity_locked: "Saved identity is locked. Enter your PIN to unlock it.",
locked: "Locked",
signer_anon: "Publishing anonymously",
signer_nip07: "Publishing as your extension identity",
signer_nsec: "Publishing as your imported key",
republish: "Re-publish to relays",
delete_flyer: "Delete flyer",
delete_title: "Delete this flyer?",
delete_message: "This asks the relays to delete it and replaces it with a deletion marker so VoxVera hides it. Deletion can't be guaranteed across every relay.",
delete_confirm: "Delete",
cancel: "Cancel",
flyer_removed: "This flyer was removed.",
deleting: "Deleting…",
deleted_done: "Deletion requested.",
republished_done: "Re-published to relays.",
not_author: "You can only delete or re-publish flyers you published.",
no_anon_npub: "No anonymous npub yet",
anon_npub_ready: "Anonymous npub ready",
npub_generated: "Anonymous npub generated:",
preview_updated: "Preview updated.",
signing_publishing: "Signing and publishing...",
fetching: "Fetching...",
fetched: "Fetched",
loading_content: "Loading content...",
load_failed: "Could not load this flyer.",
paper_size: "Paper size",
bulletin_board: "Bulletin board",
published_title: "Flyer published",
published_message: "Copy this URL to see your new flyer:",
close: "Close",
paper_letter: "US Letter (8.5 × 11 in)",
paper_a4: "A4 (210 × 297 mm)",
config_copied: "Config copied.",
lookup_placeholder: "naddr, event id, note1, nevent1, or nostr: URL",
event_reference_required: "Enter an naddr, event id, note1, or nevent1.",
relay_required: "Enter at least one wss:// relay.",
failed: "failed",
timeout: "timeout",
connection_error: "connection error",
field_too_long: "That field is too long for the printable flyer:",
relays_accepted: "Relays accepted",
too_many_attempts: "Too many attempts — wait a moment and try again.",
use_nip46: "Remote signer (NIP-46)",
connect_remote: "Connect",
signer_nip46: "Publishing via remote signer",
nip46_hint: "Paste a bunker:// link from your signer app (e.g. nsec.app, Amber).",
nip46_connecting: "Connecting — approve the request in your signer app…",
nip46_connected: "Remote signer connected.",
nip46_bad_uri: "That doesn't look like a bunker:// link.",
nip46_timeout: "The remote signer didn't respond. Open your signer app and try again.",
nip46_failed: "Couldn't connect to the remote signer.",
import_design: "Import design",
export_design: "Export design",
import_confirm_title: "Import this design?",
import_confirm_message: "This replaces the flyer currently in the editor.",
import_button: "Import",
design_imported: "Design imported.",
design_exported: "Design exported.",
invalid_design_file: "That file isn't a valid VoxVera design.",
},
es: {
back: "Volver",
copy_config: "Copiar JSON de config.",
editor: "Editor",
event_id: "ID del evento",
export_event: "Exportar JSON del evento",
fetch_render: "Buscar y renderizar",
flyer_name: "Nombre del volante",
generate_npub: "Generar npub anónimo",
language: "Idioma",
load_event: "Cargar evento",
normalized_config: "Configuración normalizada",
open_poster: "Haga clic para abrir este cartel.",
page_title: "Título de la página",
preview: "Vista previa",
print: "Imprimir volante",
print_preview: "Imprimir vista previa",
safety_link: "Seguridad y privacidad",
publish_event: "Firmar y publicar",
published_event: "Evento publicado",
share_reprint: "Comparta y reimprima.",
tab_link: "Abre y reimprime este cartel",
tab_event_id: "Reábrelo en VoxVera.org — ID del evento",
built_with: "Creado con",
viewer: "Visor",
client_title: "Cliente Nostr de VoxVera",
client_description: "Editor y visor estático para eventos fuente descentralizados de volantes.",
author_npub: "npub del autor",
nostr_address: "Dirección Nostr",
poster_url: "URL del cartel",
status: "Estado",
copy: "Copiar",
copied: "Copiado",
not_published: "No publicado",
not_generated: "No generado",
idle: "Inactivo",
checking_signer: "Comprobando firmante",
checking_identity: "Comprobando identidad",
anon_signing_nip07: "La firma anónima es predeterminada; NIP-07 detectado",
anon_signing_ready: "Firma anónima disponible; no se requiere inicio de sesión NIP-07",
connect_extension: "Conectar extensión",
use_nsec: "Usar una clave privada (nsec)",
use_key: "Usar esta clave",
remember_pin: "Recordar en este dispositivo (PIN)",
pin_prompt: "PIN (4 o más dígitos)",
unlock: "Desbloquear",
forget_key: "Olvidar",
invalid_nsec: "Eso no parece una clave nsec válida.",
pin_short: "El PIN debe tener al menos 4 dígitos.",
pin_wrong: "PIN incorrecto. Inténtalo de nuevo.",
nip07_missing: "No se encontró ninguna extensión NIP-07.",
identity_locked: "La identidad guardada está bloqueada. Introduce tu PIN para desbloquearla.",
locked: "Bloqueada",
signer_anon: "Publicando de forma anónima",
signer_nip07: "Publicando con la identidad de tu extensión",
signer_nsec: "Publicando con tu clave importada",
republish: "Volver a publicar en los relés",
delete_flyer: "Eliminar cartel",
delete_title: "¿Eliminar este cartel?",
delete_message: "Esto pide a los relés que lo eliminen y lo reemplaza por una marca de eliminación para que VoxVera lo oculte. No se puede garantizar la eliminación en todos los relés.",
delete_confirm: "Eliminar",
cancel: "Cancelar",
flyer_removed: "Este cartel fue eliminado.",
deleting: "Eliminando…",
deleted_done: "Eliminación solicitada.",
republished_done: "Vuelto a publicar en los relés.",
not_author: "Solo puedes eliminar o volver a publicar carteles que tú publicaste.",
field_too_long: "Ese campo es demasiado largo para el cartel imprimible:",
relays_accepted: "Relés que aceptaron",
too_many_attempts: "Demasiados intentos: espera un momento e inténtalo de nuevo.",
use_nip46: "Firmante remoto (NIP-46)",
connect_remote: "Conectar",
signer_nip46: "Publicando mediante firmante remoto",
nip46_hint: "Pega un enlace bunker:// de tu app de firma (p. ej. nsec.app, Amber).",
nip46_connecting: "Conectando: aprueba la solicitud en tu app de firma…",
nip46_connected: "Firmante remoto conectado.",
nip46_bad_uri: "Esto no parece un enlace bunker://.",
nip46_timeout: "El firmante remoto no respondió. Abre la app e inténtalo de nuevo.",
nip46_failed: "No se pudo conectar con el firmante remoto.",
import_design: "Importar diseño",
export_design: "Exportar diseño",
import_confirm_title: "¿Importar este diseño?",
import_confirm_message: "Esto reemplaza el folleto que está actualmente en el editor.",
import_button: "Importar",
design_imported: "Diseño importado.",
design_exported: "Diseño exportado.",
invalid_design_file: "Ese archivo no es un diseño de VoxVera válido.",
no_anon_npub: "Aún no hay npub anónimo",
anon_npub_ready: "npub anónimo listo",
npub_generated: "npub anónimo generado:",
preview_updated: "Vista previa actualizada.",
signing_publishing: "Firmando y publicando...",
fetching: "Buscando...",
fetched: "Recuperado",
loading_content: "Cargando contenido...",
load_failed: "No se pudo cargar este cartel.",
paper_size: "Tamaño de papel",
bulletin_board: "Tablón de anuncios",
published_title: "Cartel publicado",
published_message: "Copia esta URL para ver tu nuevo cartel:",
close: "Cerrar",
paper_letter: "US Letter (8,5 × 11 in)",
paper_a4: "A4 (210 × 297 mm)",
config_copied: "Configuración copiada.",
lookup_placeholder: "naddr, ID de evento, note1, nevent1 o URL nostr:",
event_reference_required: "Ingrese un naddr, ID de evento, note1 o nevent1.",
relay_required: "Ingrese al menos un relay wss://.",
failed: "falló",
timeout: "tiempo agotado",
connection_error: "error de conexión"
},
fa: {
back: "بازگشت",
copy_config: "کپی JSON پیکربندی",
editor: "ویرایشگر",
event_id: "شناسه رویداد",
export_event: "خروجی JSON رویداد",
fetch_render: "دریافت و نمایش",
flyer_name: "نام اعلامیه",
generate_npub: "ساخت npub ناشناس",
language: "زبان",
load_event: "بارگذاری رویداد",
normalized_config: "پیکربندی عادی‌شده",
open_poster: "برای باز کردن این پوستر کلیک کنید.",
page_title: "عنوان صفحه",
preview: "پیش‌نمایش",
print: "چاپ اعلامیه",
print_preview: "چاپ پیش‌نمایش",
safety_link: "ایمنی و حریم خصوصی",
publish_event: "امضا و انتشار",
published_event: "رویداد منتشرشده",
share_reprint: "به اشتراک بگذارید و دوباره چاپ کنید.",
tab_link: "این پوستر را باز و دوباره چاپ کنید",
tab_event_id: "در VoxVera.org دوباره باز کنید — شناسه رویداد",
built_with: "ساخته شده با",
viewer: "نمایشگر",
client_title: "کلاینت Nostr VoxVera",
client_description: "ویرایشگر و نمایشگر ایستا برای رویدادهای منبع اعلامیه غیرمتمرکز.",
author_npub: "npub نویسنده",
nostr_address: "نشانی Nostr",
poster_url: "URL پوستر",
status: "وضعیت",
copy: "کپی",
copied: "کپی شد",
not_published: "منتشر نشده",
not_generated: "ساخته نشده",
idle: "آماده",
checking_signer: "بررسی امضاکننده",
checking_identity: "بررسی هویت",
anon_signing_nip07: "امضای ناشناس پیش‌فرض است؛ NIP-07 شناسایی شد",
anon_signing_ready: "امضای ناشناس آماده است؛ ورود NIP-07 لازم نیست",
connect_extension: "اتصال افزونه",
use_nsec: "استفاده از کلید خصوصی (nsec)",
use_key: "استفاده از این کلید",
remember_pin: "روی این دستگاه به خاطر بسپار (PIN)",
pin_prompt: "PIN (۴ رقم یا بیشتر)",
unlock: "باز کردن قفل",
forget_key: "فراموش کردن",
invalid_nsec: "این یک کلید nsec معتبر به نظر نمی‌رسد.",
pin_short: "PIN باید حداقل ۴ رقم باشد.",
pin_wrong: "PIN نادرست است. دوباره تلاش کنید.",
nip07_missing: "هیچ افزونه NIP-07 یافت نشد.",
identity_locked: "هویت ذخیره‌شده قفل است. برای باز کردن آن PIN خود را وارد کنید.",
locked: "قفل‌شده",
signer_anon: "انتشار به‌صورت ناشناس",
signer_nip07: "انتشار با هویت افزونه شما",
signer_nsec: "انتشار با کلید واردشده شما",
republish: "انتشار مجدد در رله‌ها",
delete_flyer: "حذف پوستر",
delete_title: "این پوستر حذف شود؟",
delete_message: "این از رله‌ها می‌خواهد آن را حذف کنند و آن را با یک نشان حذف جایگزین می‌کند تا VoxVera آن را پنهان کند. حذف در همه رله‌ها تضمین نمی‌شود.",
delete_confirm: "حذف",
cancel: "لغو",
flyer_removed: "این پوستر حذف شد.",
deleting: "در حال حذف…",
deleted_done: "درخواست حذف ارسال شد.",
republished_done: "دوباره در رله‌ها منتشر شد.",
not_author: "فقط می‌توانید پوسترهایی را که خودتان منتشر کرده‌اید حذف یا دوباره منتشر کنید.",
field_too_long: "این فیلد برای پوستر قابل چاپ خیلی طولانی است:",
relays_accepted: "رله‌هایی که پذیرفتند",
too_many_attempts: "تلاش‌های بیش از حد — کمی صبر کنید و دوباره تلاش کنید.",
use_nip46: "امضاکننده از راه دور (NIP-46)",
connect_remote: "اتصال",
signer_nip46: "انتشار از طریق امضاکننده از راه دور",
nip46_hint: "پیوند bunker:// را از برنامه امضای خود وارد کنید (مثل nsec.app یا Amber).",
nip46_connecting: "در حال اتصال — درخواست را در برنامه امضا تأیید کنید…",
nip46_connected: "امضاکننده از راه دور متصل شد.",
nip46_bad_uri: "این یک پیوند bunker:// به نظر نمی‌رسد.",
nip46_timeout: "امضاکننده از راه دور پاسخ نداد. برنامه را باز کنید و دوباره تلاش کنید.",
nip46_failed: "اتصال به امضاکننده از راه دور ممکن نشد.",
import_design: "وارد کردن طرح",
export_design: "خروجی گرفتن از طرح",
import_confirm_title: "این طرح وارد شود؟",
import_confirm_message: "این جایگزین اعلامیه‌ای می‌شود که اکنون در ویرایشگر است.",
import_button: "وارد کردن",
design_imported: "طرح وارد شد.",
design_exported: "طرح خروجی گرفته شد.",
invalid_design_file: "این فایل یک طرح معتبر VoxVera نیست.",
no_anon_npub: "هنوز npub ناشناس وجود ندارد",
anon_npub_ready: "npub ناشناس آماده است",
npub_generated: "npub ناشناس ساخته شد:",
preview_updated: "پیش‌نمایش به‌روزرسانی شد.",
signing_publishing: "در حال امضا و انتشار...",
fetching: "در حال دریافت...",
fetched: "دریافت شد",
loading_content: "در حال بارگذاری محتوا...",
load_failed: "بارگذاری این پوستر ممکن نشد.",
paper_size: "اندازه کاغذ",
bulletin_board: "تابلوی اعلانات",
published_title: "پوستر منتشر شد",
published_message: "این نشانی را کپی کنید تا پوستر جدید خود را ببینید:",
close: "بستن",
paper_letter: "US Letter (8.5 × 11 اینچ)",
paper_a4: "A4 (210 × 297 میلی‌متر)",
config_copied: "پیکربندی کپی شد.",
lookup_placeholder: "naddr، شناسه رویداد، note1، nevent1 یا URL nostr:",
event_reference_required: "یک naddr، شناسه رویداد، note1 یا nevent1 وارد کنید.",
relay_required: "حداقل یک رله wss:// وارد کنید.",
failed: "ناموفق",
timeout: "پایان مهلت",
connection_error: "خطای اتصال"
},
fr: {
back: "Retour",
copy_config: "Copier le JSON config",
editor: "Editeur",
event_id: "ID d'evenement",
export_event: "Exporter le JSON de l'evenement",
fetch_render: "Charger et afficher",
flyer_name: "Nom du flyer",
generate_npub: "Generer un npub anonyme",
language: "Langue",
load_event: "Charger l'evenement",
normalized_config: "Configuration normalisee",
open_poster: "Cliquez pour ouvrir cette affiche.",
page_title: "Titre de page",
preview: "Apercu",
print: "Imprimer le flyer",
print_preview: "Imprimer l'apercu",
safety_link: "Sécurité et confidentialité",
publish_event: "Signer et publier",
published_event: "Evenement publie",
share_reprint: "Partagez et reimprimez.",
tab_link: "Ouvrir et réimprimer cette affiche",
tab_event_id: "Rouvrir sur VoxVera.org — ID de l'événement",
built_with: "Cree avec",
viewer: "Visionneuse",
client_title: "Client Nostr VoxVera",
client_description: "Editeur et visionneuse statiques pour les evenements source decentralises de flyers.",
author_npub: "npub de l'auteur",
nostr_address: "Adresse Nostr",
poster_url: "URL de l'affiche",
status: "Statut",
copy: "Copier",
copied: "Copie",
not_published: "Non publie",
not_generated: "Non genere",
idle: "Inactif",
checking_signer: "Verification du signataire",
checking_identity: "Verification de l'identite",
anon_signing_nip07: "La signature anonyme est par defaut; NIP-07 detecte",
anon_signing_ready: "Signature anonyme disponible; aucune connexion NIP-07 requise",
connect_extension: "Connecter l'extension",
use_nsec: "Utiliser une clé privée (nsec)",
use_key: "Utiliser cette clé",
remember_pin: "Se souvenir sur cet appareil (PIN)",
pin_prompt: "PIN (4 chiffres ou plus)",
unlock: "Déverrouiller",
forget_key: "Oublier",
invalid_nsec: "Cela ne ressemble pas à une clé nsec valide.",
pin_short: "Le PIN doit comporter au moins 4 chiffres.",
pin_wrong: "PIN incorrect. Veuillez réessayer.",
nip07_missing: "Aucune extension NIP-07 trouvée.",
identity_locked: "L'identité enregistrée est verrouillée. Saisissez votre PIN pour la déverrouiller.",
locked: "Verrouillée",
signer_anon: "Publication anonyme",
signer_nip07: "Publication avec l'identité de votre extension",
signer_nsec: "Publication avec votre clé importée",
republish: "Republier sur les relais",
delete_flyer: "Supprimer l'affiche",
delete_title: "Supprimer cette affiche ?",
delete_message: "Cela demande aux relais de la supprimer et la remplace par un marqueur de suppression pour que VoxVera la masque. La suppression ne peut pas être garantie sur tous les relais.",
delete_confirm: "Supprimer",
cancel: "Annuler",
flyer_removed: "Cette affiche a été supprimée.",
deleting: "Suppression…",
deleted_done: "Suppression demandée.",
republished_done: "Republiée sur les relais.",
not_author: "Vous ne pouvez supprimer ou republier que les affiches que vous avez publiées.",
field_too_long: "Ce champ est trop long pour l'affiche imprimable :",
relays_accepted: "Relais ayant accepté",
too_many_attempts: "Trop de tentatives — patientez un instant et réessayez.",
use_nip46: "Signataire distant (NIP-46)",
connect_remote: "Connecter",
signer_nip46: "Publication via un signataire distant",
nip46_hint: "Collez un lien bunker:// depuis votre app de signature (ex. nsec.app, Amber).",
nip46_connecting: "Connexion — approuvez la demande dans votre app de signature…",
nip46_connected: "Signataire distant connecté.",
nip46_bad_uri: "Cela ne ressemble pas à un lien bunker://.",
nip46_timeout: "Le signataire distant n'a pas répondu. Ouvrez l'app et réessayez.",
nip46_failed: "Impossible de se connecter au signataire distant.",
import_design: "Importer le modèle",
export_design: "Exporter le modèle",
import_confirm_title: "Importer ce modèle ?",
import_confirm_message: "Cela remplace le tract actuellement dans l'éditeur.",
import_button: "Importer",
design_imported: "Modèle importé.",
design_exported: "Modèle exporté.",
invalid_design_file: "Ce fichier n'est pas un modèle VoxVera valide.",
no_anon_npub: "Aucun npub anonyme pour le moment",
anon_npub_ready: "npub anonyme pret",
npub_generated: "npub anonyme genere :",
preview_updated: "Apercu mis a jour.",
signing_publishing: "Signature et publication...",
fetching: "Chargement...",
fetched: "Charge",
loading_content: "Chargement du contenu...",
load_failed: "Impossible de charger cette affiche.",
paper_size: "Format du papier",
bulletin_board: "Tableau d'affichage",
published_title: "Affiche publiée",
published_message: "Copiez cette URL pour voir votre nouvelle affiche :",
close: "Fermer",
paper_letter: "US Letter (8,5 × 11 po)",
paper_a4: "A4 (210 × 297 mm)",
config_copied: "Configuration copiee.",
lookup_placeholder: "naddr, ID d'evenement, note1, nevent1 ou URL nostr:",
event_reference_required: "Saisissez un naddr, ID d'evenement, note1 ou nevent1.",
relay_required: "Saisissez au moins un relais wss://.",
failed: "echec",
timeout: "delai depasse",
connection_error: "erreur de connexion"
},
he: {
back: "חזרה",
copy_config: "העתק JSON תצורה",
editor: "עורך",
event_id: "מזהה אירוע",
export_event: "ייצוא JSON אירוע",
fetch_render: "טען והצג",
flyer_name: "שם הפלייר",
generate_npub: "צור npub אנונימי",
language: "שפה",
load_event: "טען אירוע",
normalized_config: "תצורה מנורמלת",
open_poster: "לחץ כדי לפתוח כרזה זו.",
page_title: "כותרת עמוד",
preview: "תצוגה מקדימה",
print: "הדפס פלייר",
print_preview: "הדפס תצוגה מקדימה",
safety_link: "בטיחות ופרטיות",
publish_event: "חתום ופרסם",
published_event: "אירוע שפורסם",
share_reprint: "שתף והדפס מחדש.",
tab_link: "פתח והדפס מחדש את הכרזה",
tab_event_id: "פתח מחדש ב-VoxVera.org — מזהה אירוע",
built_with: "נבנה עם",
viewer: "מציג",
client_title: "לקוח Nostr של VoxVera",
client_description: "עורך ומציג סטטיים לאירועי מקור פלייר מבוזרים.",
author_npub: "npub המחבר",
nostr_address: "כתובת Nostr",
poster_url: "כתובת הכרזה",
status: "מצב",
copy: "העתק",
copied: "הועתק",
not_published: "לא פורסם",
not_generated: "לא נוצר",
idle: "ממתין",
checking_signer: "בודק חותם",
checking_identity: "בודק זהות",
anon_signing_nip07: "חתימה אנונימית היא ברירת המחדל; NIP-07 זוהה",
anon_signing_ready: "חתימה אנונימית זמינה; אין צורך בהתחברות NIP-07",
connect_extension: "חבר תוסף",
use_nsec: "השתמש במפתח פרטי (nsec)",
use_key: "השתמש במפתח זה",
remember_pin: "זכור במכשיר זה (PIN)",
pin_prompt: "PIN (4 ספרות או יותר)",
unlock: "בטל נעילה",
forget_key: "שכח",
invalid_nsec: "זה לא נראה כמו מפתח nsec תקין.",
pin_short: "ה-PIN חייב להכיל לפחות 4 ספרות.",
pin_wrong: "PIN שגוי. נסה שוב.",
nip07_missing: "לא נמצא תוסף NIP-07.",
identity_locked: "הזהות השמורה נעולה. הזן את ה-PIN שלך כדי לבטל את הנעילה.",
locked: "נעול",
signer_anon: "מפרסם באופן אנונימי",
signer_nip07: "מפרסם עם זהות התוסף שלך",
signer_nsec: "מפרסם עם המפתח המיובא שלך",
republish: "פרסם מחדש לממסרים",
delete_flyer: "מחק כרזה",
delete_title: "למחוק את הכרזה הזו?",
delete_message: "פעולה זו מבקשת מהממסרים למחוק אותה ומחליפה אותה בסימן מחיקה כך ש-VoxVera יסתיר אותה. לא ניתן להבטיח מחיקה בכל הממסרים.",
delete_confirm: "מחק",
cancel: "ביטול",
flyer_removed: "כרזה זו הוסרה.",
deleting: "מוחק…",
deleted_done: "התבקשה מחיקה.",
republished_done: "פורסם מחדש לממסרים.",
not_author: "ניתן למחוק או לפרסם מחדש רק כרזות שפרסמת.",
field_too_long: "השדה הזה ארוך מדי עבור הכרזה הניתנת להדפסה:",
relays_accepted: "ממסרים שקיבלו",
too_many_attempts: "יותר מדי ניסיונות — המתן רגע ונסה שוב.",
use_nip46: "חותם מרוחק (NIP-46)",
connect_remote: "התחבר",
signer_nip46: "פרסום באמצעות חותם מרוחק",
nip46_hint: "הדבק קישור bunker:// מאפליקציית החתימה שלך (למשל nsec.app או Amber).",
nip46_connecting: "מתחבר — אשר את הבקשה באפליקציית החתימה…",
nip46_connected: "החותם המרוחק מחובר.",
nip46_bad_uri: "זה לא נראה כמו קישור bunker://.",
nip46_timeout: "החותם המרוחק לא הגיב. פתח את האפליקציה ונסה שוב.",
nip46_failed: "לא ניתן להתחבר לחותם המרוחק.",
import_design: "ייבוא עיצוב",
export_design: "ייצוא עיצוב",
import_confirm_title: "לייבא את העיצוב הזה?",
import_confirm_message: "פעולה זו מחליפה את הפלייר שנמצא כעת בעורך.",
import_button: "ייבוא",
design_imported: "העיצוב יובא.",
design_exported: "העיצוב יוצא.",
invalid_design_file: "הקובץ הזה אינו עיצוב VoxVera תקין.",
no_anon_npub: "עדיין אין npub אנונימי",
anon_npub_ready: "npub אנונימי מוכן",
npub_generated: "npub אנונימי נוצר:",
preview_updated: "התצוגה המקדימה עודכנה.",
signing_publishing: "חותם ומפרסם...",
fetching: "טוען...",
fetched: "נטען",
loading_content: "טוען תוכן...",
load_failed: "לא ניתן לטעון כרזה זו.",
paper_size: "גודל נייר",
bulletin_board: "לוח מודעות",
published_title: "הכרזה פורסמה",
published_message: "העתק כתובת זו כדי לראות את הכרזה החדשה שלך:",
close: "סגור",
paper_letter: "US Letter (8.5 × 11 אינץ׳)",
paper_a4: "A4 (210 × 297 מ״מ)",
config_copied: "התצורה הועתקה.",
lookup_placeholder: "naddr, מזהה אירוע, note1, nevent1 או כתובת nostr:",
event_reference_required: "הזן naddr, מזהה אירוע, note1 או nevent1.",
relay_required: "הזן לפחות relay אחד מסוג wss://.",
failed: "נכשל",
timeout: "פג הזמן",
connection_error: "שגיאת חיבור"
},
hi: {
back: "पीछे",
copy_config: "कॉन्फिग JSON कॉपी करें",
editor: "संपादक",
event_id: "इवेंट ID",
export_event: "इवेंट JSON निर्यात करें",
fetch_render: "लाएं और दिखाएं",
flyer_name: "फ्लायर नाम",
generate_npub: "अनाम npub बनाएं",
language: "भाषा",
load_event: "इवेंट लोड करें",
normalized_config: "सामान्यीकृत कॉन्फिग",
open_poster: "इस पोस्टर को खोलने के लिए क्लिक करें।",
page_title: "पृष्ठ शीर्षक",
preview: "पूर्वावलोकन",
print: "फ्लायर प्रिंट करें",
print_preview: "पूर्वावलोकन प्रिंट करें",
safety_link: "सुरक्षा और निजता",
publish_event: "हस्ताक्षर कर प्रकाशित करें",
published_event: "प्रकाशित इवेंट",
share_reprint: "साझा करें और फिर से प्रिंट करें।",
tab_link: "इस पोस्टर को खोलें और फिर से प्रिंट करें",
tab_event_id: "VoxVera.org पर फिर से खोलें — इवेंट ID",
built_with: "इसके साथ बनाया गया",
viewer: "दर्शक",
client_title: "VoxVera Nostr क्लाइंट",
client_description: "विकेंद्रीकृत फ्लायर स्रोत इवेंट के लिए स्थिर संपादक और दर्शक।",
author_npub: "लेखक npub",
nostr_address: "Nostr पता",
poster_url: "पोस्टर URL",
status: "स्थिति",
copy: "कॉपी करें",
copied: "कॉपी किया गया",
not_published: "प्रकाशित नहीं",
not_generated: "बनाया नहीं गया",
idle: "निष्क्रिय",
checking_signer: "हस्ताक्षरकर्ता जांच रहे हैं",
checking_identity: "पहचान जांच रहे हैं",
anon_signing_nip07: "अनाम हस्ताक्षर डिफ़ॉल्ट है; NIP-07 मिला",
anon_signing_ready: "अनाम हस्ताक्षर उपलब्ध; NIP-07 लॉगिन आवश्यक नहीं",
connect_extension: "एक्सटेंशन कनेक्ट करें",
use_nsec: "निजी कुंजी (nsec) का उपयोग करें",
use_key: "इस कुंजी का उपयोग करें",
remember_pin: "इस डिवाइस पर याद रखें (PIN)",
pin_prompt: "PIN (4+ अंक)",
unlock: "अनलॉक करें",
forget_key: "भूल जाएँ",
invalid_nsec: "यह एक मान्य nsec कुंजी नहीं लगती।",
pin_short: "PIN कम से कम 4 अंकों का होना चाहिए।",
pin_wrong: "गलत PIN। पुनः प्रयास करें।",
nip07_missing: "कोई NIP-07 एक्सटेंशन नहीं मिला।",
identity_locked: "सहेजी गई पहचान लॉक है। इसे अनलॉक करने के लिए अपना PIN दर्ज करें।",
locked: "लॉक",
signer_anon: "अनाम रूप से प्रकाशित किया जा रहा है",
signer_nip07: "आपकी एक्सटेंशन पहचान से प्रकाशित किया जा रहा है",
signer_nsec: "आपकी आयातित कुंजी से प्रकाशित किया जा रहा है",
republish: "रिले पर फिर से प्रकाशित करें",
delete_flyer: "पोस्टर हटाएँ",
delete_title: "इस पोस्टर को हटाएँ?",
delete_message: "यह रिले से इसे हटाने का अनुरोध करता है और इसे एक विलोपन चिह्न से बदल देता है ताकि VoxVera इसे छिपा दे। हर रिले पर विलोपन की गारंटी नहीं दी जा सकती।",
delete_confirm: "हटाएँ",
cancel: "रद्द करें",
flyer_removed: "यह पोस्टर हटा दिया गया।",
deleting: "हटाया जा रहा है…",
deleted_done: "विलोपन का अनुरोध किया गया।",
republished_done: "रिले पर फिर से प्रकाशित किया गया।",
not_author: "आप केवल अपने द्वारा प्रकाशित पोस्टर ही हटा या फिर से प्रकाशित कर सकते हैं।",
field_too_long: "यह फ़ील्ड प्रिंट करने योग्य पोस्टर के लिए बहुत लंबा है:",
relays_accepted: "स्वीकृत रिले",
too_many_attempts: "बहुत अधिक प्रयास — थोड़ी देर रुकें और पुनः प्रयास करें।",
use_nip46: "रिमोट साइनर (NIP-46)",
connect_remote: "कनेक्ट करें",
signer_nip46: "रिमोट साइनर के माध्यम से प्रकाशन",
nip46_hint: "अपने साइनर ऐप से bunker:// लिंक पेस्ट करें (जैसे nsec.app, Amber)।",
nip46_connecting: "कनेक्ट हो रहा है — अपने साइनर ऐप में अनुरोध स्वीकृत करें…",
nip46_connected: "रिमोट साइनर कनेक्ट हो गया।",
nip46_bad_uri: "यह bunker:// लिंक जैसा नहीं लगता।",
nip46_timeout: "रिमोट साइनर ने उत्तर नहीं दिया। ऐप खोलें और पुनः प्रयास करें।",
nip46_failed: "रिमोट साइनर से कनेक्ट नहीं हो सका।",
import_design: "डिज़ाइन आयात करें",
export_design: "डिज़ाइन निर्यात करें",
import_confirm_title: "यह डिज़ाइन आयात करें?",
import_confirm_message: "यह संपादक में मौजूद फ़्लायर को बदल देगा।",
import_button: "आयात करें",
design_imported: "डिज़ाइन आयात किया गया।",
design_exported: "डिज़ाइन निर्यात किया गया।",
invalid_design_file: "वह फ़ाइल मान्य VoxVera डिज़ाइन नहीं है।",
no_anon_npub: "अभी कोई अनाम npub नहीं",
anon_npub_ready: "अनाम npub तैयार",
npub_generated: "अनाम npub बनाया गया:",
preview_updated: "पूर्वावलोकन अपडेट हुआ.",
signing_publishing: "हस्ताक्षर और प्रकाशन...",
fetching: "लाया जा रहा है...",
fetched: "लाया गया",
loading_content: "सामग्री लोड हो रही है...",
load_failed: "यह पोस्टर लोड नहीं हो सका।",
paper_size: "कागज़ का आकार",
bulletin_board: "बुलेटिन बोर्ड",
published_title: "पोस्टर प्रकाशित",
published_message: "अपना नया पोस्टर देखने के लिए यह URL कॉपी करें:",
close: "बंद करें",
paper_letter: "US Letter (8.5 × 11 इंच)",
paper_a4: "A4 (210 × 297 मिमी)",
config_copied: "कॉन्फिग कॉपी हुआ.",
lookup_placeholder: "naddr, इवेंट ID, note1, nevent1, या nostr: URL",
event_reference_required: "naddr, इवेंट ID, note1, या nevent1 दर्ज करें.",
relay_required: "कम से कम एक wss:// relay दर्ज करें.",
failed: "विफल",
timeout: "समय समाप्त",
connection_error: "कनेक्शन त्रुटि"
},
ja: {
back: "戻る",
copy_config: "設定JSONをコピー",
editor: "エディター",
event_id: "イベントID",
export_event: "イベントJSONを書き出す",
fetch_render: "取得して表示",
flyer_name: "フライヤー名",
generate_npub: "匿名npubを生成",
language: "言語",
load_event: "イベントを読み込む",
normalized_config: "正規化設定",
open_poster: "クリックしてこのポスターを開く。",
page_title: "ページタイトル",
preview: "プレビュー",
print: "フライヤーを印刷",
print_preview: "プレビューを印刷",
safety_link: "安全とプライバシー",
publish_event: "署名して公開",
published_event: "公開済みイベント",
share_reprint: "共有して再印刷。",
tab_link: "開いて再印刷",
tab_event_id: "VoxVera.org で再度開く — イベントID",
built_with: "作成:",
viewer: "ビューア",
client_title: "VoxVera Nostr クライアント",
client_description: "分散型フライヤーソースイベント用の静的エディターとビューア。",
author_npub: "作者 npub",
nostr_address: "Nostr アドレス",
poster_url: "ポスター URL",
status: "状態",
copy: "コピー",
copied: "コピーしました",
not_published: "未公開",
not_generated: "未生成",
idle: "待機中",
checking_signer: "署名者を確認中",
checking_identity: "IDを確認中",
anon_signing_nip07: "匿名署名が標準です; NIP-07を検出しました",
anon_signing_ready: "匿名署名を利用できます; NIP-07ログインは不要です",
connect_extension: "拡張機能を接続",
use_nsec: "秘密鍵 (nsec) を使う",
use_key: "この鍵を使う",
remember_pin: "このデバイスで記憶する (PIN)",
pin_prompt: "PIN(4桁以上)",
unlock: "ロック解除",
forget_key: "破棄",
invalid_nsec: "有効な nsec 鍵ではないようです。",
pin_short: "PIN は4桁以上にしてください。",
pin_wrong: "PIN が違います。もう一度お試しください。",
nip07_missing: "NIP-07 拡張機能が見つかりません。",
identity_locked: "保存された ID はロックされています。PIN を入力して解除してください。",
locked: "ロック中",
signer_anon: "匿名で公開します",
signer_nip07: "拡張機能の ID で公開します",
signer_nsec: "インポートした鍵で公開します",
republish: "リレーに再公開",
delete_flyer: "ちらしを削除",
delete_title: "このちらしを削除しますか?",
delete_message: "リレーに削除を要求し、削除マーカーに置き換えて VoxVera で非表示にします。すべてのリレーでの削除は保証できません。",
delete_confirm: "削除",
cancel: "キャンセル",
flyer_removed: "このちらしは削除されました。",
deleting: "削除しています…",
deleted_done: "削除を要求しました。",
republished_done: "リレーに再公開しました。",
not_author: "自分が公開したちらしのみ削除または再公開できます。",
field_too_long: "この項目は印刷用ちらしには長すぎます:",
relays_accepted: "受け入れたリレー",
too_many_attempts: "試行回数が多すぎます。少し待ってからもう一度お試しください。",
use_nip46: "リモート署名(NIP-46)",
connect_remote: "接続",
signer_nip46: "リモート署名で公開",
nip46_hint: "署名アプリの bunker:// リンクを貼り付けてください(例:nsec.app、Amber)。",
nip46_connecting: "接続中 — 署名アプリでリクエストを承認してください…",
nip46_connected: "リモート署名に接続しました。",
nip46_bad_uri: "これは bunker:// リンクではないようです。",
nip46_timeout: "リモート署名が応答しませんでした。アプリを開いて再試行してください。",
nip46_failed: "リモート署名に接続できませんでした。",
import_design: "デザインを読み込む",
export_design: "デザインを書き出す",
import_confirm_title: "このデザインを読み込みますか?",
import_confirm_message: "現在エディターにあるフライヤーが置き換えられます。",
import_button: "読み込む",
design_imported: "デザインを読み込みました。",
design_exported: "デザインを書き出しました。",
invalid_design_file: "そのファイルは有効な VoxVera デザインではありません。",
no_anon_npub: "匿名npubはまだありません",
anon_npub_ready: "匿名npub準備完了",
npub_generated: "匿名npubを生成しました:",
preview_updated: "プレビューを更新しました。",
signing_publishing: "署名して公開中...",
fetching: "取得中...",
fetched: "取得しました",
loading_content: "コンテンツを読み込んでいます...",
load_failed: "このちらしを読み込めませんでした。",
paper_size: "用紙サイズ",
bulletin_board: "掲示板",
published_title: "ちらしを公開しました",
published_message: "新しいちらしを見るにはこの URL をコピーしてください:",
close: "閉じる",
paper_letter: "US レター (8.5 × 11 インチ)",
paper_a4: "A4 (210 × 297 mm)",
config_copied: "設定をコピーしました。",
lookup_placeholder: "naddr、イベントID、note1、nevent1、または nostr: URL",
event_reference_required: "naddr、イベントID、note1、またはnevent1を入力してください。",
relay_required: "少なくとも1つの wss:// リレーを入力してください。",
failed: "失敗",
timeout: "タイムアウト",
connection_error: "接続エラー"
},
pt: {
back: "Voltar",
copy_config: "Copiar JSON de config.",
editor: "Editor",
event_id: "ID do evento",
export_event: "Exportar JSON do evento",
fetch_render: "Buscar e renderizar",
flyer_name: "Nome do panfleto",
generate_npub: "Gerar npub anonimo",
language: "Idioma",
load_event: "Carregar evento",
normalized_config: "Configuracao normalizada",
open_poster: "Clique para abrir este cartaz.",
page_title: "Titulo da pagina",
preview: "Previa",
print: "Imprimir Panfleto",
print_preview: "Imprimir previa",
safety_link: "Segurança e privacidade",
publish_event: "Assinar e publicar",
published_event: "Evento publicado",
share_reprint: "Compartilhe e reimprima.",
tab_link: "Abrir e reimprimir este panfleto",
tab_event_id: "Reabrir em VoxVera.org — ID do evento",
built_with: "Criado com",
viewer: "Visualizador",
client_title: "Cliente Nostr VoxVera",
client_description: "Editor e visualizador estatico para eventos-fonte descentralizados de panfletos.",
author_npub: "npub do autor",
nostr_address: "Endereco Nostr",
poster_url: "URL do cartaz",
status: "Status",
copy: "Copiar",
copied: "Copiado",
not_published: "Nao publicado",
not_generated: "Nao gerado",
idle: "Ocioso",
checking_signer: "Verificando assinante",
checking_identity: "Verificando identidade",
anon_signing_nip07: "Assinatura anonima e o padrao; NIP-07 detectado",
anon_signing_ready: "Assinatura anonima disponivel; login NIP-07 nao necessario",
connect_extension: "Conectar extensão",
use_nsec: "Usar uma chave privada (nsec)",
use_key: "Usar esta chave",
remember_pin: "Lembrar neste dispositivo (PIN)",
pin_prompt: "PIN (4 ou mais dígitos)",
unlock: "Desbloquear",
forget_key: "Esquecer",
invalid_nsec: "Isso não parece uma chave nsec válida.",
pin_short: "O PIN deve ter pelo menos 4 dígitos.",
pin_wrong: "PIN incorreto. Tente novamente.",
nip07_missing: "Nenhuma extensão NIP-07 encontrada.",
identity_locked: "A identidade salva está bloqueada. Digite seu PIN para desbloqueá-la.",
locked: "Bloqueada",
signer_anon: "Publicando anonimamente",
signer_nip07: "Publicando com a identidade da sua extensão",
signer_nsec: "Publicando com sua chave importada",
republish: "Republicar nos relés",
delete_flyer: "Excluir panfleto",
delete_title: "Excluir este panfleto?",
delete_message: "Isso pede aos relés que o excluam e o substitui por um marcador de exclusão para que o VoxVera o oculte. A exclusão não pode ser garantida em todos os relés.",
delete_confirm: "Excluir",
cancel: "Cancelar",
flyer_removed: "Este panfleto foi removido.",
deleting: "Excluindo…",
deleted_done: "Exclusão solicitada.",
republished_done: "Republicado nos relés.",
not_author: "Você só pode excluir ou republicar panfletos que você publicou.",
field_too_long: "Esse campo é longo demais para o panfleto imprimível:",
relays_accepted: "Relés que aceitaram",
too_many_attempts: "Tentativas em excesso — aguarde um momento e tente novamente.",
use_nip46: "Assinador remoto (NIP-46)",
connect_remote: "Conectar",
signer_nip46: "Publicando via assinador remoto",
nip46_hint: "Cole um link bunker:// do seu app de assinatura (ex.: nsec.app, Amber).",
nip46_connecting: "Conectando — aprove a solicitação no seu app de assinatura…",
nip46_connected: "Assinador remoto conectado.",
nip46_bad_uri: "Isso não parece um link bunker://.",
nip46_timeout: "O assinador remoto não respondeu. Abra o app e tente novamente.",
nip46_failed: "Não foi possível conectar ao assinador remoto.",
import_design: "Importar design",
export_design: "Exportar design",
import_confirm_title: "Importar este design?",
import_confirm_message: "Isto substitui o panfleto atualmente no editor.",
import_button: "Importar",
design_imported: "Design importado.",
design_exported: "Design exportado.",
invalid_design_file: "Esse arquivo não é um design válido do VoxVera.",
no_anon_npub: "Ainda nao ha npub anonimo",
anon_npub_ready: "npub anonimo pronto",
npub_generated: "npub anonimo gerado:",
preview_updated: "Previa atualizada.",
signing_publishing: "Assinando e publicando...",
fetching: "Buscando...",
fetched: "Buscado",
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",
published_title: "Panfleto publicado",
published_message: "Copie este URL para ver seu novo panfleto:",
close: "Fechar",
paper_letter: "US Letter (8,5 × 11 pol)",
paper_a4: "A4 (210 × 297 mm)",
config_copied: "Configuracao copiada.",
lookup_placeholder: "naddr, ID do evento, note1, nevent1 ou URL nostr:",
event_reference_required: "Insira um naddr, ID do evento, note1 ou nevent1.",
relay_required: "Insira pelo menos um relay wss://.",
failed: "falhou",
timeout: "tempo esgotado",
connection_error: "erro de conexao"
},
ru: {
back: "Назад",
copy_config: "Копировать JSON конфигурации",
editor: "Редактор",
event_id: "ID события",
export_event: "Экспорт JSON события",
fetch_render: "Загрузить и отобразить",
flyer_name: "Название листовки",
generate_npub: "Создать анонимный npub",
language: "Язык",
load_event: "Загрузить событие",
normalized_config: "Нормализованная конфигурация",
open_poster: "Нажмите, чтобы открыть этот плакат.",
page_title: "Заголовок страницы",
preview: "Предпросмотр",
print: "Печать листовки",
print_preview: "Печать предпросмотра",
safety_link: "Безопасность и конфиденциальность",
publish_event: "Подписать и опубликовать",
published_event: "Опубликованное событие",
share_reprint: "Поделитесь и распечатайте снова.",
tab_link: "Открыть и распечатать снова",
tab_event_id: "Открыть снова на VoxVera.org — ID события",
built_with: "Создано с помощью",
viewer: "Просмотр",
client_title: "Клиент VoxVera Nostr",
client_description: "Статический редактор и просмотрщик для децентрализованных исходных событий листовок.",
author_npub: "npub автора",
nostr_address: "Адрес Nostr",
poster_url: "URL плаката",
status: "Статус",
copy: "Копировать",
copied: "Скопировано",
not_published: "Не опубликовано",
not_generated: "Не создано",
idle: "Ожидание",
checking_signer: "Проверка подписанта",
checking_identity: "Проверка личности",
anon_signing_nip07: "Анонимная подпись используется по умолчанию; обнаружен NIP-07",
anon_signing_ready: "Анонимная подпись доступна; вход NIP-07 не требуется",
connect_extension: "Подключить расширение",
use_nsec: "Использовать приватный ключ (nsec)",
use_key: "Использовать этот ключ",
remember_pin: "Запомнить на этом устройстве (PIN)",
pin_prompt: "PIN (не менее 4 цифр)",
unlock: "Разблокировать",
forget_key: "Забыть",
invalid_nsec: "Это не похоже на действительный ключ nsec.",
pin_short: "PIN должен содержать не менее 4 цифр.",
pin_wrong: "Неверный PIN. Попробуйте снова.",
nip07_missing: "Расширение NIP-07 не найдено.",
identity_locked: "Сохранённая личность заблокирована. Введите PIN, чтобы разблокировать её.",
locked: "Заблокировано",
signer_anon: "Публикация анонимно",
signer_nip07: "Публикация под личностью вашего расширения",
signer_nsec: "Публикация вашим импортированным ключом",
republish: "Опубликовать снова на реле",
delete_flyer: "Удалить листовку",
delete_title: "Удалить эту листовку?",
delete_message: "Это попросит реле удалить её и заменит её маркером удаления, чтобы VoxVera её скрыл. Удаление на всех реле не гарантируется.",
delete_confirm: "Удалить",
cancel: "Отмена",
flyer_removed: "Эта листовка удалена.",
deleting: "Удаление…",
deleted_done: "Запрошено удаление.",
republished_done: "Снова опубликовано на реле.",
not_author: "Вы можете удалять или повторно публиковать только свои листовки.",
field_too_long: "Это поле слишком длинное для печатной листовки:",
relays_accepted: "Реле приняли",
too_many_attempts: "Слишком много попыток — подождите немного и попробуйте снова.",
use_nip46: "Удалённый подписант (NIP-46)",
connect_remote: "Подключить",
signer_nip46: "Публикация через удалённый подписант",
nip46_hint: "Вставьте ссылку bunker:// из вашего приложения для подписи (например, nsec.app, Amber).",
nip46_connecting: "Подключение — подтвердите запрос в приложении для подписи…",
nip46_connected: "Удалённый подписант подключён.",
nip46_bad_uri: "Это не похоже на ссылку bunker://.",
nip46_timeout: "Удалённый подписант не ответил. Откройте приложение и попробуйте снова.",
nip46_failed: "Не удалось подключиться к удалённому подписанту.",
import_design: "Импорт дизайна",
export_design: "Экспорт дизайна",
import_confirm_title: "Импортировать этот дизайн?",
import_confirm_message: "Это заменит листовку, открытую сейчас в редакторе.",
import_button: "Импорт",
design_imported: "Дизайн импортирован.",
design_exported: "Дизайн экспортирован.",
invalid_design_file: "Этот файл не является корректным дизайном VoxVera.",
no_anon_npub: "Анонимного npub пока нет",
anon_npub_ready: "Анонимный npub готов",
npub_generated: "Анонимный npub создан:",
preview_updated: "Предпросмотр обновлен.",
signing_publishing: "Подписание и публикация...",
fetching: "Загрузка...",
fetched: "Загружено",
loading_content: "Загрузка содержимого...",
load_failed: "Не удалось загрузить эту листовку.",
paper_size: "Размер бумаги",
bulletin_board: "Доска объявлений",
published_title: "Листовка опубликована",
published_message: "Скопируйте этот URL, чтобы увидеть свою листовку:",
close: "Закрыть",
paper_letter: "US Letter (8,5 × 11 дюйма)",
paper_a4: "A4 (210 × 297 мм)",
config_copied: "Конфигурация скопирована.",
lookup_placeholder: "naddr, ID события, note1, nevent1 или URL nostr:",
event_reference_required: "Введите naddr, ID события, note1 или nevent1.",
relay_required: "Введите хотя бы один relay wss://.",
failed: "сбой",
timeout: "тайм-аут",
connection_error: "ошибка соединения"
},
sw: {
back: "Rudi",
copy_config: "Nakili JSON ya usanidi",
editor: "Kihariri",
event_id: "Kitambulisho cha tukio",
export_event: "Hamisha JSON ya tukio",
fetch_render: "Pakua na onyesha",
flyer_name: "Jina la kipeperushi",
generate_npub: "Tengeneza npub isiyojulikana",
language: "Lugha",
load_event: "Pakia tukio",
normalized_config: "Usanidi uliorekebishwa",
open_poster: "Bofya kufungua bango hili.",
page_title: "Kichwa cha ukurasa",
preview: "Hakiki",
print: "Chapa Kipeperushi",
print_preview: "Chapa hakiki",
safety_link: "Usalama na faragha",
publish_event: "Saini na chapisha",
published_event: "Tukio lililochapishwa",
share_reprint: "Shiriki na uchapishe tena.",
tab_link: "Fungua na uchapishe tena bango hili",
tab_event_id: "Fungua tena kwenye VoxVera.org — Kitambulisho cha tukio",
built_with: "Imejengwa na",
viewer: "Kitazamaji",
client_title: "Mteja wa VoxVera Nostr",
client_description: "Kihariri na kitazamaji tuli kwa matukio chanzo ya vipeperushi vilivyogatuliwa.",
author_npub: "npub ya mwandishi",
nostr_address: "Anwani ya Nostr",
poster_url: "URL ya bango",
status: "Hali",
copy: "Nakili",
copied: "Imenakiliwa",
not_published: "Haijachapishwa",
not_generated: "Haijatengenezwa",
idle: "Tayari",
checking_signer: "Inakagua mtia saini",
checking_identity: "Inakagua utambulisho",
anon_signing_nip07: "Utiaji saini usiojulikana ndio chaguo msingi; NIP-07 imegunduliwa",
anon_signing_ready: "Utiaji saini usiojulikana unapatikana; hakuna kuingia kwa NIP-07 kunakohitajika",
connect_extension: "Unganisha kiendelezi",
use_nsec: "Tumia ufunguo wa faragha (nsec)",
use_key: "Tumia ufunguo huu",
remember_pin: "Kumbuka kwenye kifaa hiki (PIN)",
pin_prompt: "PIN (tarakimu 4 au zaidi)",
unlock: "Fungua",
forget_key: "Sahau",
invalid_nsec: "Huu hauonekani kama ufunguo halali wa nsec.",
pin_short: "PIN lazima iwe na tarakimu nne au zaidi.",
pin_wrong: "PIN si sahihi. Jaribu tena.",
nip07_missing: "Hakuna kiendelezi cha NIP-07 kilichopatikana.",
identity_locked: "Kitambulisho kilichohifadhiwa kimefungwa. Weka PIN yako kukifungua.",
locked: "Imefungwa",
signer_anon: "Inachapisha bila kujulikana",
signer_nip07: "Inachapisha kwa kitambulisho cha kiendelezi chako",
signer_nsec: "Inachapisha kwa ufunguo wako ulioingizwa",
republish: "Chapisha tena kwenye relay",
delete_flyer: "Futa bango",
delete_title: "Futa bango hili?",
delete_message: "Hii inaomba relay zifute na kulibadilisha na alama ya kufuta ili VoxVera ilifiche. Kufuta hakuwezi kuhakikishwa kwenye kila relay.",
delete_confirm: "Futa",
cancel: "Ghairi",
flyer_removed: "Bango hili limeondolewa.",
deleting: "Inafuta…",
deleted_done: "Kufuta kumeombwa.",
republished_done: "Limechapishwa tena kwenye relay.",
not_author: "Unaweza tu kufuta au kuchapisha tena mabango uliyochapisha.",
field_too_long: "Sehemu hii ni ndefu mno kwa bango linaloweza kuchapishwa:",
relays_accepted: "Relay zilizokubali",
too_many_attempts: "Majaribio mengi mno — subiri kidogo kisha ujaribu tena.",
use_nip46: "Mtia-saini wa mbali (NIP-46)",
connect_remote: "Unganisha",
signer_nip46: "Inachapisha kupitia mtia-saini wa mbali",
nip46_hint: "Bandika kiungo cha bunker:// kutoka programu yako ya kutia saini (k.m. nsec.app, Amber).",
nip46_connecting: "Inaunganisha — idhinisha ombi katika programu yako ya kutia saini…",
nip46_connected: "Mtia-saini wa mbali ameunganishwa.",
nip46_bad_uri: "Hiki hakionekani kama kiungo cha bunker://.",
nip46_timeout: "Mtia-saini wa mbali hakujibu. Fungua programu na ujaribu tena.",
nip46_failed: "Imeshindwa kuunganisha na mtia-saini wa mbali.",
import_design: "Leta muundo",
export_design: "Hamisha muundo",
import_confirm_title: "Ulete muundo huu?",
import_confirm_message: "Hii itachukua nafasi ya kipeperushi kilichopo sasa kwenye kihariri.",
import_button: "Leta",
design_imported: "Muundo umeletwa.",
design_exported: "Muundo umehamishwa.",
invalid_design_file: "Faili hilo si muundo halali wa VoxVera.",
no_anon_npub: "Hakuna npub isiyojulikana bado",
anon_npub_ready: "npub isiyojulikana iko tayari",
npub_generated: "npub isiyojulikana imetengenezwa:",
preview_updated: "Hakiki imesasishwa.",
signing_publishing: "Inatia saini na kuchapisha...",
fetching: "Inapakua...",
fetched: "Imepakuliwa",
loading_content: "Inapakia maudhui...",
load_failed: "Imeshindwa kupakia bango hili.",
paper_size: "Ukubwa wa karatasi",
bulletin_board: "Ubao wa matangazo",
published_title: "Bango limechapishwa",
published_message: "Nakili URL hii ili kuona bango lako jipya:",
close: "Funga",
paper_letter: "US Letter (8.5 × 11 inchi)",
paper_a4: "A4 (210 × 297 mm)",
config_copied: "Usanidi umenakiliwa.",
lookup_placeholder: "naddr, kitambulisho cha tukio, note1, nevent1 au URL ya nostr:",
event_reference_required: "Weka naddr, kitambulisho cha tukio, note1 au nevent1.",
relay_required: "Weka angalau relay moja ya wss://.",
failed: "imeshindwa",
timeout: "muda umeisha",
connection_error: "hitilafu ya muunganisho"
},
tr: {
back: "Geri",
copy_config: "Yapılandırma JSON'unu kopyala",
editor: "Düzenleyici",
event_id: "Olay ID",
export_event: "Olay JSON'unu dışa aktar",
fetch_render: "Getir ve göster",
flyer_name: "İlan adı",
generate_npub: "Anonim npub oluştur",
language: "Dil",
load_event: "Olay yükle",
normalized_config: "Normalleştirilmiş yapılandırma",
open_poster: "Bu posteri açmak için tıklayın.",
page_title: "Sayfa başlığı",
preview: "Önizleme",
print: "İlanı Yazdır",
print_preview: "Önizlemeyi yazdır",
safety_link: "Güvenlik ve gizlilik",
publish_event: "İmzala ve yayınla",
published_event: "Yayınlanan olay",
share_reprint: "Paylaşın ve yeniden yazdırın.",
tab_link: "Bu el ilanını aç ve yeniden yazdır",
tab_event_id: "VoxVera.org'da yeniden aç — Olay ID",
built_with: "İle yapıldı",
viewer: "Görüntüleyici",
client_title: "VoxVera Nostr İstemcisi",
client_description: "Merkeziyetsiz ilan kaynak olayları için statik düzenleyici ve görüntüleyici.",
author_npub: "Yazar npub",
nostr_address: "Nostr adresi",
poster_url: "Poster URL'si",
status: "Durum",
copy: "Kopyala",
copied: "Kopyalandı",
not_published: "Yayınlanmadı",
not_generated: "Oluşturulmadı",
idle: "Boşta",
checking_signer: "İmzalayan kontrol ediliyor",
checking_identity: "Kimlik kontrol ediliyor",
anon_signing_nip07: "Anonim imzalama varsayılandır; NIP-07 algılandı",
anon_signing_ready: "Anonim imzalama hazır; NIP-07 girişi gerekmez",
connect_extension: "Uzantıyı bağla",
use_nsec: "Özel anahtar (nsec) kullan",
use_key: "Bu anahtarı kullan",
remember_pin: "Bu cihazda hatırla (PIN)",
pin_prompt: "PIN (4+ rakam)",
unlock: "Kilidi aç",
forget_key: "Unut",
invalid_nsec: "Bu geçerli bir nsec anahtarı gibi görünmüyor.",
pin_short: "PIN en az 4 rakam olmalıdır.",
pin_wrong: "Yanlış PIN. Tekrar deneyin.",
nip07_missing: "NIP-07 uzantısı bulunamadı.",
identity_locked: "Kayıtlı kimlik kilitli. Kilidini açmak için PIN'inizi girin.",
locked: "Kilitli",
signer_anon: "Anonim olarak yayımlanıyor",
signer_nip07: "Uzantı kimliğinizle yayımlanıyor",
signer_nsec: "İçe aktardığınız anahtarla yayımlanıyor",
republish: "Rölelere yeniden yayımla",
delete_flyer: "El ilanını sil",
delete_title: "Bu el ilanı silinsin mi?",
delete_message: "Bu, rölelerden silmesini ister ve VoxVera'nın gizlemesi için bir silme işaretiyle değiştirir. Silme her rölede garanti edilemez.",
delete_confirm: "Sil",
cancel: "İptal",
flyer_removed: "Bu el ilanı kaldırıldı.",
deleting: "Siliniyor…",
deleted_done: "Silme istendi.",
republished_done: "Rölelere yeniden yayımlandı.",
not_author: "Yalnızca kendi yayımladığın el ilanlarını silebilir veya yeniden yayımlayabilirsin.",
field_too_long: "Bu alan yazdırılabilir el ilanı için çok uzun:",
relays_accepted: "Kabul eden röleler",
too_many_attempts: "Çok fazla deneme — biraz bekleyip tekrar deneyin.",
use_nip46: "Uzak imzalayıcı (NIP-46)",
connect_remote: "Bağlan",
signer_nip46: "Uzak imzalayıcı ile yayımlanıyor",
nip46_hint: "İmzalama uygulamanızdan bir bunker:// bağlantısı yapıştırın (ör. nsec.app, Amber).",
nip46_connecting: "Bağlanıyor — isteği imzalama uygulamanızda onaylayın…",
nip46_connected: "Uzak imzalayıcı bağlandı.",
nip46_bad_uri: "Bu bir bunker:// bağlantısına benzemiyor.",
nip46_timeout: "Uzak imzalayıcı yanıt vermedi. Uygulamayı açıp tekrar deneyin.",
nip46_failed: "Uzak imzalayıcıya bağlanılamadı.",
import_design: "Tasarımı içe aktar",
export_design: "Tasarımı dışa aktar",
import_confirm_title: "Bu tasarım içe aktarılsın mı?",
import_confirm_message: "Bu, düzenleyicide şu anda bulunan el ilanının yerini alır.",
import_button: "İçe aktar",
design_imported: "Tasarım içe aktarıldı.",
design_exported: "Tasarım dışa aktarıldı.",
invalid_design_file: "Bu dosya geçerli bir VoxVera tasarımı değil.",
no_anon_npub: "Henüz anonim npub yok",
anon_npub_ready: "Anonim npub hazır",
npub_generated: "Anonim npub oluşturuldu:",
preview_updated: "Önizleme güncellendi.",
signing_publishing: "İmzalanıyor ve yayınlanıyor...",
fetching: "Getiriliyor...",
fetched: "Getirildi",
loading_content: "İçerik yükleniyor...",
load_failed: "Bu el ilanı yüklenemedi.",
paper_size: "Kağıt boyutu",
bulletin_board: "İlan panosu",
published_title: "El ilanı yayımlandı",
published_message: "Yeni el ilanınızı görmek için bu URL'yi kopyalayın:",
close: "Kapat",
paper_letter: "US Letter (8,5 × 11 inç)",
paper_a4: "A4 (210 × 297 mm)",
config_copied: "Yapılandırma kopyalandı.",
lookup_placeholder: "naddr, olay ID, note1, nevent1 veya nostr: URL",
event_reference_required: "Bir naddr, olay ID, note1 veya nevent1 girin.",
relay_required: "En az bir wss:// relay girin.",
failed: "başarısız",
timeout: "zaman aşımı",
connection_error: "bağlantı hatası"
},
zh: {
back: "返回",
copy_config: "复制配置 JSON",
editor: "编辑器",
event_id: "事件 ID",
export_event: "导出事件 JSON",
fetch_render: "获取并渲染",
flyer_name: "传单名称",
generate_npub: "生成匿名 npub",
language: "语言",
load_event: "加载事件",
normalized_config: "标准化配置",
open_poster: "点击打开此海报。",
page_title: "页面标题",
preview: "预览",
print: "打印传单",
print_preview: "打印预览",
safety_link: "安全与隐私",
publish_event: "签名并发布",
published_event: "已发布事件",
share_reprint: "分享并重新打印。",
tab_link: "打开并重新打印此传单",
tab_event_id: "在 VoxVera.org 重新打开 — 事件 ID",
built_with: "构建自",
viewer: "查看器",
client_title: "VoxVera Nostr 客户端",
client_description: "用于去中心化传单源事件的静态编辑器和查看器。",
author_npub: "作者 npub",
nostr_address: "Nostr 地址",
poster_url: "海报 URL",
status: "状态",
copy: "复制",
copied: "已复制",
not_published: "未发布",
not_generated: "未生成",
idle: "空闲",
checking_signer: "正在检查签名器",
checking_identity: "正在检查身份",
anon_signing_nip07: "默认使用匿名签名;检测到 NIP-07",
anon_signing_ready: "匿名签名可用;无需 NIP-07 登录",
connect_extension: "连接扩展",
use_nsec: "使用私钥 (nsec)",
use_key: "使用此密钥",
remember_pin: "在此设备上记住 (PIN)",
pin_prompt: "PIN(4位以上)",
unlock: "解锁",
forget_key: "忘记",
invalid_nsec: "这看起来不是有效的 nsec 密钥。",
pin_short: "PIN 至少需要 4 位数字。",
pin_wrong: "PIN 错误。请重试。",
nip07_missing: "未找到 NIP-07 扩展。",
identity_locked: "已保存的身份已锁定。请输入 PIN 解锁。",
locked: "已锁定",
signer_anon: "正在匿名发布",
signer_nip07: "正在以你的扩展身份发布",
signer_nsec: "正在使用你导入的密钥发布",
republish: "重新发布到中继",
delete_flyer: "删除传单",
delete_title: "删除此传单?",
delete_message: "这将请求中继删除它,并用删除标记替换它,使 VoxVera 将其隐藏。无法保证在每个中继上都删除。",
delete_confirm: "删除",
cancel: "取消",
flyer_removed: "此传单已被移除。",
deleting: "正在删除…",
deleted_done: "已请求删除。",
republished_done: "已重新发布到中继。",
not_author: "你只能删除或重新发布你自己发布的传单。",
field_too_long: "该字段对于可打印传单来说太长了:",
relays_accepted: "已接受的中继",
too_many_attempts: "尝试次数过多——请稍候再试。",
use_nip46: "远程签名器(NIP-46)",
connect_remote: "连接",
signer_nip46: "通过远程签名器发布",
nip46_hint: "粘贴来自签名应用的 bunker:// 链接(如 nsec.app、Amber)。",
nip46_connecting: "正在连接——请在签名应用中批准请求…",
nip46_connected: "远程签名器已连接。",
nip46_bad_uri: "这看起来不是 bunker:// 链接。",
nip46_timeout: "远程签名器无响应。请打开应用并重试。",
nip46_failed: "无法连接到远程签名器。",
import_design: "导入设计",
export_design: "导出设计",
import_confirm_title: "导入此设计?",
import_confirm_message: "这将替换编辑器中当前的传单。",
import_button: "导入",
design_imported: "设计已导入。",
design_exported: "设计已导出。",
invalid_design_file: "该文件不是有效的 VoxVera 设计。",
no_anon_npub: "还没有匿名 npub",
anon_npub_ready: "匿名 npub 已就绪",
npub_generated: "已生成匿名 npub:",
preview_updated: "预览已更新。",
signing_publishing: "正在签名并发布...",
fetching: "正在获取...",
fetched: "已获取",
loading_content: "正在加载内容...",
load_failed: "无法加载此传单。",
paper_size: "纸张大小",
bulletin_board: "公告栏",
published_title: "传单已发布",
published_message: "复制此网址以查看您的新传单:",
close: "关闭",
paper_letter: "US Letter (8.5 × 11 英寸)",
paper_a4: "A4 (210 × 297 毫米)",
config_copied: "配置已复制。",
lookup_placeholder: "naddr、事件 ID、note1、nevent1 或 nostr: URL",
event_reference_required: "请输入 naddr、事件 ID、note1 或 nevent1。",
relay_required: "请输入至少一个 wss:// relay。",
failed: "失败",
timeout: "超时",
connection_error: "连接错误"
}
};
let flyerSource = "default";
let loadedEventConfig = null;
let loadedEventId = "";
let loadedEventPubkey = "";
const lastAcceptedFieldValues = {};
const el = (id) => document.getElementById(id);
function localeData(lang) {
return LOCALES[lang] || LOCALES[FALLBACK_LANG] || {};
}
function supportedLang(lang) {
return LOCALES[lang] ? lang : FALLBACK_LANG;
}
function translate(path, lang) {
const data = localeData(supportedLang(lang));
return path.split(".").reduce((value, key) => (value && value[key] !== undefined ? value[key] : undefined), data);
}
function htmlBreaksToNewlines(value) {
return String(value || "").replace(/<br\s*\/?>/gi, "\n");
}
function nostrLabel(key, lang) {
const selected = supportedLang(lang);
return (NOSTR_UI[selected] && NOSTR_UI[selected][key])
|| (NOSTR_UI[FALLBACK_LANG] && NOSTR_UI[FALLBACK_LANG][key])
|| key;
}
function currentUiLang() {
return supportedLang((el("field-lang") && el("field-lang").value) || getStoredUiLang() || FALLBACK_LANG);
}
function setLocalizedText(id, key, lang = currentUiLang()) {
const node = el(id);
if (!node) return;
node.dataset.i18nKey = key;
node.textContent = nostrLabel(key, lang);
}
function setEmptyOutput(id, key, lang = currentUiLang()) {
const node = el(id);
if (!node) return;
node.dataset.emptyKey = key;
node.textContent = nostrLabel(key, lang);
}
function setRealOutput(id, value) {
const node = el(id);
if (!node) return;
delete node.dataset.emptyKey;
node.textContent = value;
}
function isEmptyOutput(node) {
return Boolean(node && node.dataset.emptyKey);
}
// The "too long" warning is shown inline under the offending field (so the
// issue is visible without scrolling to a single shared status line). The
// message element is created lazily inside the field's <label>.
function fieldErrorElement(fieldId) {
const field = el(fieldId);
const label = field ? field.closest("label") : null;
if (!label) return null;
let node = label.querySelector(".field-error");
if (!node) {
node = document.createElement("p");
node.className = "field-error";
node.setAttribute("role", "alert");
node.hidden = true;
label.appendChild(node);
}
return node;
}
function setFieldError(fieldId, message) {
const node = fieldErrorElement(fieldId);
if (node) {
node.textContent = message || "";
node.hidden = !message;
}
const field = el(fieldId);
if (field) {
if (message) field.setAttribute("aria-invalid", "true");
else field.removeAttribute("aria-invalid");
}
}
function clearAllFieldErrors() {
document.querySelectorAll("#editor-form .field-error").forEach((node) => {
node.textContent = "";
node.hidden = true;
});
TEXT_FIELD_IDS.forEach((fieldId) => {
const field = el(fieldId);
if (field) field.removeAttribute("aria-invalid");
});
}
function syncAcceptedFieldValues() {
TEXT_FIELD_IDS.forEach((fieldId) => {
const field = el(fieldId);
if (field) lastAcceptedFieldValues[fieldId] = field.value;
});
}
function fillEditorFromConfig(config) {
const normalized = config && config.type === "voxvera_flyer" ? normalizePayload(config) : {
...CONFIG_DEFAULTS,
...config,
folder_name: slugify(config && config.folder_name),
lang: supportedLang((config && config.lang) || FALLBACK_LANG),
content: htmlBreaksToNewlines((config && config.content) || CONFIG_DEFAULTS.content)
};
el("field-lang").value = normalized.lang;
el("field-folder-name").value = normalized.folder_name;
el("field-name").value = normalized.name;
el("field-title").value = normalized.title;
el("field-subtitle").value = normalized.subtitle;
el("field-headline").value = normalized.headline;
el("field-content").value = normalized.content;
el("field-url-message").value = normalized.url_message;
el("field-url").value = normalized.url;
el("field-footer-message").value = normalized.footer_message;
syncUiLanguage(normalized.lang);
syncAcceptedFieldValues();
clearAllFieldErrors();
}
function printableTitle() {
const source = (el("field-name") && el("field-name").value) || CONFIG_DEFAULTS.name;
return String(source || CONFIG_DEFAULTS.name)
.trim()
.replace(/\s+/g, " ")
.slice(0, FIELD_LIMITS.name) || CONFIG_DEFAULTS.name;
}
function printWithPageTitle() {
const previousTitle = document.title;
document.title = printableTitle();
const restoreTitle = () => {
document.title = nostrLabel("client_title", currentUiLang()) || previousTitle;
window.removeEventListener("focus", restoreTitle);
};
window.addEventListener("focus", restoreTitle, { once: true });
window.print();
window.setTimeout(restoreTitle, 1500);
}
function localeDefaults(lang) {
const data = localeData(lang);
const landing = data.landing || {};
return {
...CONFIG_DEFAULTS,
name: landing.name || CONFIG_DEFAULTS.name,
folder_name: slugify(landing.name || CONFIG_DEFAULTS.folder_name),
lang: supportedLang(lang),
title: landing.title || CONFIG_DEFAULTS.title,
subtitle: landing.subtitle || CONFIG_DEFAULTS.subtitle,
headline: landing.headline || CONFIG_DEFAULTS.headline,
content: htmlBreaksToNewlines(landing.content || CONFIG_DEFAULTS.content),
url_message: landing.url_message || CONFIG_DEFAULTS.url_message,
url: CONFIG_DEFAULTS.url,
tear_off_link: "",
footer_message: CONFIG_DEFAULTS.footer_message,
attachment_path: "",
attachment_filename: ""
};
}
function setText(id, value) {
const node = el(id);
if (node) node.textContent = value;
}
function setButtonText(id, value) {
const node = el(id);
if (node) node.textContent = value;
}
function populateLanguageOptions() {
const langEntries = Object.entries(LOCALES).sort((a, b) => {
const aName = (a[1] && a[1].meta && a[1].meta.language_name) || a[0];
const bName = (b[1] && b[1].meta && b[1].meta.language_name) || b[0];
return aName.localeCompare(bName);
});
LANGUAGE_SELECT_IDS.forEach((selectId) => {
const select = el(selectId);
if (!select) return;
select.innerHTML = "";
for (const [code, data] of langEntries) {
const option = document.createElement("option");
option.value = code;
const meta = data.meta || {};
option.textContent = `${meta.flag || ""} ${meta.language_name || code}`.trim();
select.appendChild(option);
}
});
}
function syncUiLanguage(lang) {
const selected = supportedLang(lang);
const data = localeData(selected);
const meta = data.meta || {};
document.documentElement.lang = selected;
document.documentElement.dir = meta.direction || "ltr";
document.title = nostrLabel("client_title", selected);
try {
window.localStorage.setItem(UI_LANG_STORAGE_KEY, selected);
} catch (_) {}
Object.entries(UI_LABELS).forEach(([id, path]) => {
const translated = translate(path, selected);
if (translated) setText(id, translated);
});
Object.entries(NOSTR_TEXT_LABELS).forEach(([id, key]) => setText(id, nostrLabel(key, selected)));
Object.entries(HEADING_LABELS).forEach(([id, key]) => setText(id, nostrLabel(key, selected)));
Object.entries(STATIC_TEXT_LABELS).forEach(([id, key]) => setText(id, nostrLabel(key, selected)));
Object.entries(BUTTON_LABELS).forEach(([id, key]) => setButtonText(id, nostrLabel(key, selected)));
setButtonText("print-preview", translate("web.print_button", selected) || nostrLabel("print_preview", selected));
setButtonText("viewer-print", translate("web.print_button", selected) || nostrLabel("print", selected));
setButtonText("viewer-editor", nostrLabel("editor", selected));
setButtonText("close-viewer-controls", nostrLabel("back", selected));
document.querySelectorAll(".copy-button").forEach((button) => {
button.textContent = nostrLabel("copy", selected);
});
Object.entries(EMPTY_OUTPUT_LABELS).forEach(([id, key]) => {
const node = el(id);
if (!node) return;
if (!node.dataset.emptyKey && ["Not published", "Not generated"].includes(node.textContent.trim())) {
node.dataset.emptyKey = key;
}
if (isEmptyOutput(node)) setEmptyOutput(id, node.dataset.emptyKey, selected);
});
document.querySelectorAll("[data-i18n-key]").forEach((node) => {
node.textContent = nostrLabel(node.dataset.i18nKey, selected);
});
const viewerEventInput = el("viewer-event-id");
if (viewerEventInput) viewerEventInput.placeholder = nostrLabel("lookup_placeholder", selected);
["nsec-pin", "unlock-pin"].forEach((id) => {
const node = el(id);
if (node) node.placeholder = nostrLabel("pin_prompt", selected);
});
LANGUAGE_SELECT_IDS.forEach((selectId) => {
const select = el(selectId);
if (select) select.value = selected;
});
PAPER_SELECT_IDS.forEach((selectId) => {
const select = el(selectId);
if (!select) return;
select.setAttribute("aria-label", nostrLabel("paper_size", selected));
Array.from(select.options).forEach((opt) => {
opt.textContent = nostrLabel(`paper_${opt.value}`, selected);
});
});
}
function setDefaultText(lang) {
const relayText = DEFAULT_RELAYS.join("\n");
el("editor-relays").value = relayText;
el("viewer-relays").value = relayText;
populateLanguageOptions();
populatePaperOptions();
const defaultLang = supportedLang(lang || getStoredUiLang() || preferredBrowserLang());
applyFlyerDefaults(defaultLang);
applyPaperSize(getStoredPaper() || preferredPaperSize());
Object.entries(EMPTY_OUTPUT_LABELS).forEach(([id, key]) => setEmptyOutput(id, key, defaultLang));
setLocalizedText("publish-status", "idle", defaultLang);
setLocalizedText("viewer-status", "idle", defaultLang);
setLocalizedText("signer-state", "checking_signer", defaultLang);
setLocalizedText("identity-state", "checking_identity", defaultLang);
renderPreview(localeDefaults(defaultLang));
refreshSignerState();
refreshIdentityState();
}
function getStoredUiLang() {
try {
const stored = window.localStorage.getItem(UI_LANG_STORAGE_KEY);
return stored && LOCALES[stored] ? stored : "";
} catch (_) {
return "";
}
}
// Walk the browser's full ordered preference list (navigator.languages),
// not just the single top choice, and return the first base language we
// actually support. This honors a user's secondary preference (e.g. prefers
// Dutch, then German) instead of jumping straight to the English fallback.
function preferredBrowserLang() {
const list = (navigator.languages && navigator.languages.length)
? navigator.languages
: [navigator.language || ""];
for (const tag of list) {
const base = String(tag || "").split("-")[0];
if (base && LOCALES[base]) return base;
}
return "";
}
function supportedPaper(paper) {
return PAPER_SIZES[paper] ? paper : DEFAULT_PAPER;
}
function getStoredPaper() {
try {
const stored = window.localStorage.getItem(PAPER_STORAGE_KEY);
return stored && PAPER_SIZES[stored] ? stored : "";
} catch (_) {
return "";
}
}
// "By location" without geolocation: read the region subtag of the browser's
// locale list (the "GB" in "en-GB") and pick the paper that country prints on.
// Same signal we use for language, no permission prompt. Falls back to
// DEFAULT_PAPER only when no locale carries a region at all.
function preferredPaperSize() {
const list = (navigator.languages && navigator.languages.length)
? navigator.languages
: [navigator.language || ""];
for (const tag of list) {
const parts = String(tag || "").split("-");
const region = parts.length > 1 ? parts[parts.length - 1].toUpperCase() : "";
if (/^[A-Z]{2}$/.test(region)) {
return LETTER_REGIONS.has(region) ? "letter" : "a4";
}
}
return DEFAULT_PAPER;
}
function ensurePageSizeStyle() {
let style = document.getElementById("voxvera-page-size");
if (!style) {
style = document.createElement("style");
style.id = "voxvera-page-size";
document.head.appendChild(style);
}
return style;
}
// Paper size is a local print/view preference — it is never written into the
// Nostr event, so a flyer authored on Letter reprints on A4 for a European
// viewer without changing the source event.
function applyPaperSize(paper) {
const selected = supportedPaper(paper);
const root = document.documentElement;
root.classList.remove("paper-letter", "paper-a4");
root.classList.add(`paper-${selected}`);
// @page size cannot read CSS custom properties reliably across engines, so
// drive it from an injected stylesheet that we rewrite on each change.
ensurePageSizeStyle().textContent =
`@media print { @page { size: ${PAPER_SIZES[selected].css} portrait; margin: 0; } }`;
try {
window.localStorage.setItem(PAPER_STORAGE_KEY, selected);
} catch (_) {}
PAPER_SELECT_IDS.forEach((id) => {
const sel = el(id);
if (sel) sel.value = selected;
});
updatePreviewScale();
}
function populatePaperOptions() {
PAPER_SELECT_IDS.forEach((id) => {
const sel = el(id);
if (!sel || sel.options.length) return;
Object.keys(PAPER_SIZES).forEach((key) => {
const opt = document.createElement("option");
opt.value = key;
sel.appendChild(opt);
});
});
}
function applyFlyerDefaults(lang) {
const defaults = localeDefaults(lang);
flyerSource = "default";
loadedEventId = "";
el("field-lang").value = defaults.lang;
el("field-folder-name").value = defaults.folder_name;
el("field-name").value = defaults.name;
el("field-title").value = defaults.title;
el("field-subtitle").value = defaults.subtitle;
el("field-headline").value = defaults.headline;
el("field-content").value = defaults.content;
el("field-url-message").value = defaults.url_message;
el("field-url").value = defaults.url;
el("field-footer-message").value = defaults.footer_message;
syncUiLanguage(defaults.lang);
syncAcceptedFieldValues();
clearAllFieldErrors();
}
function applyLanguageChange(lang) {
const selected = supportedLang(lang);
if (flyerSource === "default") {
applyFlyerDefaults(selected);
renderPreview(buildPayloadFromForm());
return;
}
el("field-lang").value = selected;
syncUiLanguage(selected);
renderPreview({ ...normalizePayload(buildPayloadFromForm()), event_id: loadedEventId });
}
function refreshSignerState() {
const state = el("signer-state");
if (!state) return;
const key = identityMode === "nip07" ? "signer_nip07"
: identityMode === "nsec" ? "signer_nsec"
: identityMode === "nip46" ? "signer_nip46"
: "signer_anon";
setLocalizedText("signer-state", key);
state.style.color = "#1d6b3a";
}
function nostrTools() {
if (!window.NostrTools) {
throw new Error("Nostr tools bundle did not load.");
}
return window.NostrTools;
}
function bytesToHex(bytes) {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}
function hexToBytes(hex) {
if (!/^[a-f0-9]{64}$/i.test(hex)) throw new Error("Invalid anonymous secret key.");
const bytes = new Uint8Array(32);
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = parseInt(hex.slice(index * 2, index * 2 + 2), 16);
}
return bytes;
}
function getStoredAnonSecret() {
try {
const stored = window.localStorage.getItem(ANON_SECRET_STORAGE_KEY);
return stored && /^[a-f0-9]{64}$/i.test(stored) ? stored : "";
} catch (_) {
return "";
}
}
function storeAnonSecret(secretHex) {
try {
window.localStorage.setItem(ANON_SECRET_STORAGE_KEY, secretHex);
} catch (_) {}
}
function getOrCreateAnonIdentity(forceNew = false) {
const tools = nostrTools();
let secretHex = forceNew ? "" : getStoredAnonSecret();
if (!secretHex) {
secretHex = bytesToHex(tools.generateSecretKey());
storeAnonSecret(secretHex);
}
const secretKey = hexToBytes(secretHex);
const pubkey = tools.getPublicKey(secretKey);
const npub = tools.nip19.npubEncode(pubkey);
const nsec = tools.nip19.nsecEncode(secretKey);
return { secretKey, secretHex, pubkey, npub, nsec };
}
// ---- Publishing identity (anon / NIP-07 / imported nsec) ----------------
// Flyers are signed under one of three identities. "anon" is the per-device
// key (default, unchanged). "nip07" signs via the browser extension. "nsec"
// signs with a key the user imported; its secret lives only in memory unless
// they choose to remember it, in which case it is PIN-encrypted at rest.
let identityMode = "anon"; // "anon" | "nip07" | "nsec" | "nip46"
let nip07Pubkey = null; // hex, when mode === "nip07"
let importedSecretHex = null; // hex, in-memory only, when mode === "nsec"
let importedPubkey = null; // hex, when mode === "nsec"
let lockedNpub = null; // npub of a stored-but-locked nsec identity
let nip46Pubkey = null; // hex user pubkey, when mode === "nip46"
let nip46Signer = null; // live remote-signer connection, when mode === "nip46"
function npubFromHex(pubkeyHex) {
try { return nostrTools().nip19.npubEncode(pubkeyHex); } catch (_) { return pubkeyHex; }
}
// The identity flyers are currently published under: { mode, pubkey, npub }
// (never the secret). Falls back to the device anon key.
function activeIdentity() {
if (identityMode === "nip07" && nip07Pubkey) {
return { mode: "nip07", pubkey: nip07Pubkey, npub: npubFromHex(nip07Pubkey) };
}
if (identityMode === "nsec" && importedPubkey) {
return { mode: "nsec", pubkey: importedPubkey, npub: npubFromHex(importedPubkey) };
}
if (identityMode === "nip46" && nip46Pubkey) {
return { mode: "nip46", pubkey: nip46Pubkey, npub: npubFromHex(nip46Pubkey) };
}
const anon = getOrCreateAnonIdentity(false);
return { mode: "anon", pubkey: anon.pubkey, npub: anon.npub };
}
// Resolve the concrete pubkey the active identity will sign with. For NIP-07
// this fetches the extension's pubkey if it has not loaded yet, so the poster
// naddr is always built from the SAME key that signs (rather than silently
// falling back to the anonymous key — which produced flyers whose naddr
// pointed at the wrong author).
async function resolveActivePubkey() {
if (identityMode === "nip07") {
if (!nip07Pubkey) {
if (!(window.nostr && typeof window.nostr.getPublicKey === "function")) {
throw new Error(nostrLabel("nip07_missing", currentUiLang()));
}
const pk = await window.nostr.getPublicKey();
if (!/^[0-9a-f]{64}$/i.test(String(pk || ""))) {
throw new Error(nostrLabel("nip07_missing", currentUiLang()));
}
nip07Pubkey = String(pk).toLowerCase();
}
return nip07Pubkey;
}
if (identityMode === "nsec") {
if (!importedPubkey) throw new Error(nostrLabel("identity_locked", currentUiLang()));
return importedPubkey;
}
if (identityMode === "nip46") {
if (!nip46Signer || !nip46Pubkey) throw new Error(nostrLabel("nip46_failed", currentUiLang()));
return nip46Pubkey;
}
return getOrCreateAnonIdentity(false).pubkey;
}
// Sign an unsigned event with the active identity (extension or local key).
// pubkeyHex is the resolved signing key, used so the NIP-07 event carries the
// same pubkey the naddr was built from.
async function signActiveEvent(unsigned, pubkeyHex) {
const tools = nostrTools();
if (identityMode === "nip07") {
if (!(window.nostr && typeof window.nostr.signEvent === "function")) {
throw new Error(nostrLabel("nip07_missing", currentUiLang()));
}
return await window.nostr.signEvent({ ...unsigned, pubkey: pubkeyHex || nip07Pubkey });
}
if (identityMode === "nsec") {
if (!importedSecretHex) throw new Error(nostrLabel("identity_locked", currentUiLang()));
return tools.finalizeEvent(unsigned, hexToBytes(importedSecretHex));
}
if (identityMode === "nip46") {
if (!nip46Signer) throw new Error(nostrLabel("nip46_failed", currentUiLang()));
// The remote signer signs as the user's key; ask it to sign the template
// (carrying the resolved pubkey so it matches the naddr we built).
const toSign = { ...unsigned, pubkey: pubkeyHex || nip46Pubkey };
const res = await nip46Signer.request("sign_event", [JSON.stringify(toSign)], NIP46_SIGN_TIMEOUT_MS);
let signed;
try { signed = typeof res === "string" ? JSON.parse(res) : res; } catch (_) {
throw new Error(nostrLabel("nip46_failed", currentUiLang()));
}
return signed;
}
return tools.finalizeEvent(unsigned, getOrCreateAnonIdentity(false).secretKey);
}
function setIdentityError(messageKey) {
const node = el("identity-error");
if (!node) return;
if (messageKey) {
node.hidden = false;
node.textContent = nostrLabel(messageKey, currentUiLang());
} else {
node.hidden = true;
node.textContent = "";
}
}
function persistMode(mode) {
try {
window.localStorage.setItem(IDENTITY_MODE_STORAGE_KEY, mode);
if (mode === "anon") {
window.localStorage.removeItem(IDENTITY_NPUB_STORAGE_KEY);
} else {
window.localStorage.setItem(IDENTITY_NPUB_STORAGE_KEY, activeIdentity().npub);
}
} catch (_) {}
}
// Mirror a deliberately-chosen identity's pubkey to the board's connected-key
// slot, so the editor and board share one identity on this device.
function syncConnectedPubkey(pubkeyHex) {
if (!/^[0-9a-f]{64}$/i.test(String(pubkeyHex || ""))) return;
try { window.localStorage.setItem(BOARD_CONNECTED_PUBKEY_KEY, String(pubkeyHex).toLowerCase()); } catch (_) {}
}
// --- WebCrypto PIN encryption for a remembered nsec (PBKDF2 -> AES-GCM) ---
function b64encode(bytes) {
let bin = "";
bytes.forEach((b) => { bin += String.fromCharCode(b); });
return window.btoa(bin);
}
function b64decode(text) {
const bin = window.atob(text);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i);
return out;
}
async function deriveAesKey(pin, salt, usage) {
const subtle = window.crypto.subtle;
const material = await subtle.importKey(
"raw", new TextEncoder().encode(pin), "PBKDF2", false, ["deriveKey"]
);
return subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" },
material, { name: "AES-GCM", length: 256 }, false, usage
);
}
async function encryptSecretHex(secretHex, pin) {
const salt = window.crypto.getRandomValues(new Uint8Array(16));
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const key = await deriveAesKey(pin, salt, ["encrypt"]);
const ct = await window.crypto.subtle.encrypt(
{ name: "AES-GCM", iv }, key, new TextEncoder().encode(secretHex)
);
return {
v: 1, iters: PBKDF2_ITERATIONS,
salt: b64encode(salt), iv: b64encode(iv), ct: b64encode(new Uint8Array(ct))
};
}
async function decryptSecretHex(blob, pin) {
const salt = b64decode(blob.salt);
const iv = b64decode(blob.iv);
const key = await deriveAesKey(pin, salt, ["decrypt"]);
const pt = await window.crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, b64decode(blob.ct));
const hex = new TextDecoder().decode(pt).trim();
if (!/^[0-9a-f]{64}$/i.test(hex)) throw new Error("bad plaintext");
return hex.toLowerCase();
}
function getStoredEnc() {
try {
const raw = window.localStorage.getItem(IDENTITY_ENC_STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch (_) {
return null;
}
}
function showLockedRow(show) {
const row = el("identity-locked-row");
if (row) row.hidden = !show;
}
// --- NIP-46 remote signer (bunker) -----------------------------------------
// We hand-roll a minimal NIP-46 client over a raw WebSocket (the vendored
// nostr-tools bundle ships nip04/nip44 + key helpers but no nip46 module).
// The local key is ephemeral and lives only in memory: the user's real secret
// never reaches the browser — the remote signer holds it and signs on request.
// bunker://<remote-signer-pubkey-hex>?relay=wss://...&secret=<token>
function parseBunkerUri(uri) {
const text = String(uri || "").trim();
const match = text.match(/^bunker:\/\/([0-9a-f]{64})\??([\s\S]*)$/i);
if (!match) return null;
const remotePubkey = match[1].toLowerCase();
let params;
try { params = new URLSearchParams(match[2] || ""); } catch (_) { return null; }
const relays = params.getAll("relay").filter((r) => /^wss?:\/\//i.test(r));
if (!relays.length) return null;
return { remotePubkey, relays, secret: params.get("secret") || "" };
}
// Encrypt/decrypt the JSON-RPC payload. NIP-46 moved from NIP-04 to NIP-44, so
// we encrypt requests with NIP-44 (modern signers) but accept either scheme on
// responses for backward compatibility with older signers.
function nip46Codec(localSkHex, remotePubkey) {
const tools = nostrTools();
let convKey = null;
try { convKey = tools.nip44.getConversationKey(hexToBytes(localSkHex), remotePubkey); } catch (_) {}
return {
encrypt(text) {
if (convKey) return tools.nip44.encrypt(text, convKey);
return tools.nip04.encrypt(localSkHex, remotePubkey, text);
},
decrypt(ciphertext) {
if (convKey) {
try { return tools.nip44.decrypt(ciphertext, convKey); } catch (_) {}
}
return tools.nip04.decrypt(localSkHex, remotePubkey, ciphertext);
}
};
}
function openNip46Connection({ relay, remotePubkey, localSkHex, localPubkey }) {
const tools = nostrTools();
const codec = nip46Codec(localSkHex, remotePubkey);
const subId = "voxvera46-" + Math.random().toString(36).slice(2);
const pending = new Map(); // request id -> { resolve, reject, timer }
let ws = null;
let closed = false;
function rejectAll(err) {
pending.forEach(({ reject, timer }) => { clearTimeout(timer); reject(err); });
pending.clear();
}
function handleEvent(ev) {
if (!ev || ev.kind !== NIP46_KIND || ev.pubkey !== remotePubkey) return;
let text;
try { text = codec.decrypt(ev.content); } catch (_) { return; }
let msg;
try { msg = JSON.parse(text); } catch (_) { return; }
if (!msg || !msg.id) return;
const slot = pending.get(msg.id);
if (!slot) return;
// Some signers (e.g. nsec.app) first reply asking the user to approve via
// a URL, then send the real result with the same id. Open it and wait.
if (msg.result === "auth_url") {
const url = msg.error || "";
if (/^https?:\/\//i.test(url)) { try { window.open(url, "_blank", "noopener"); } catch (_) {} }
return;
}
pending.delete(msg.id);
clearTimeout(slot.timer);
if (msg.error) slot.reject(new Error(String(msg.error)));
else slot.resolve(msg.result);
}
function open() {
return new Promise((resolve, reject) => {
try { ws = new WebSocket(relay); } catch (e) { reject(e); return; }
const openTimer = setTimeout(() => {
try { ws.close(); } catch (_) {}
reject(new Error("relay connect timeout"));
}, NIP46_RELAY_TIMEOUT_MS);
ws.onopen = () => {
clearTimeout(openTimer);
const since = Math.floor(Date.now() / 1000) - 10;
ws.send(JSON.stringify(["REQ", subId, { kinds: [NIP46_KIND], "#p": [localPubkey], since }]));
resolve();
};
ws.onmessage = (m) => {
let data;
try { data = JSON.parse(m.data); } catch (_) { return; }
if (data[0] === "EVENT" && data[1] === subId) handleEvent(data[2]);
};
ws.onerror = () => {};
ws.onclose = () => { if (!closed) rejectAll(new Error("relay disconnected")); };
});
}
function request(method, params, timeoutMs) {
return new Promise((resolve, reject) => {
if (closed || !ws || ws.readyState !== 1 /* WebSocket.OPEN */) {
reject(new Error("remote signer not connected"));
return;
}
const id = Math.random().toString(36).slice(2);
let content;
try { content = codec.encrypt(JSON.stringify({ id, method, params })); }
catch (e) { reject(e); return; }
let signed;
try {
signed = tools.finalizeEvent({
kind: NIP46_KIND,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", remotePubkey]],
content
}, hexToBytes(localSkHex));
} catch (e) { reject(e); return; }
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error(nostrLabel("nip46_timeout", currentUiLang())));
}, timeoutMs || NIP46_SIGN_TIMEOUT_MS);
pending.set(id, { resolve, reject, timer });
try { ws.send(JSON.stringify(["EVENT", signed])); }
catch (e) { pending.delete(id); clearTimeout(timer); reject(e); }
});
}
function close() {
closed = true;
rejectAll(new Error("disconnected"));
try { ws && ws.close(); } catch (_) {}
ws = null;
}
return { open, request, close };
}
function disconnectNip46() {
if (nip46Signer) { try { nip46Signer.close(); } catch (_) {} }
nip46Signer = null;
nip46Pubkey = null;
}
function toggleNip46Import() {
setIdentityError(null);
showLockedRow(false);
const nsec = el("nsec-import");
if (nsec) nsec.hidden = true;
const row = el("nip46-import");
if (!row) return;
row.hidden = !row.hidden;
if (!row.hidden) { const i = el("nip46-input"); if (i) i.focus(); }
}
async function submitNip46Import() {
setIdentityError(null);
const input = el("nip46-input");
const parsed = parseBunkerUri(input && input.value);
if (!parsed) { setIdentityError("nip46_bad_uri"); return; }
const status = el("nip46-status");
if (status) status.textContent = nostrLabel("nip46_connecting", currentUiLang());
const tools = nostrTools();
const skBytes = tools.generateSecretKey();
const localSkHex = bytesToHex(skBytes);
const localPubkey = tools.getPublicKey(skBytes);
const conn = openNip46Connection({
relay: parsed.relays[0], remotePubkey: parsed.remotePubkey, localSkHex, localPubkey
});
try {
await conn.open();
await conn.request("connect", [parsed.remotePubkey, parsed.secret || ""], NIP46_CONNECT_TIMEOUT_MS);
const userPubkey = await conn.request("get_public_key", [], NIP46_CONNECT_TIMEOUT_MS);
if (!/^[0-9a-f]{64}$/i.test(String(userPubkey || ""))) throw new Error("bad pubkey");
disconnectNip46();
nip46Signer = conn;
nip46Pubkey = String(userPubkey).toLowerCase();
identityMode = "nip46";
if (input) input.value = "";
if (status) status.textContent = "";
const row = el("nip46-import");
if (row) row.hidden = true;
// Session-only: the bunker token is never written to disk, so a reload
// returns cleanly to anonymous rather than a dead connection.
persistMode("anon");
syncConnectedPubkey(nip46Pubkey);
refreshSignerState();
refreshIdentityState();
} catch (error) {
try { conn.close(); } catch (_) {}
if (status) status.textContent = "";
const timedOut = /timeout/i.test(String(error && error.message));
setIdentityError(timedOut ? "nip46_timeout" : "nip46_failed");
}
}
// --- Identity actions wired to the controls ---
function chooseAnonIdentity() {
disconnectNip46();
identityMode = "anon";
nip07Pubkey = null;
setIdentityError(null);
const imp = el("nsec-import");
if (imp) imp.hidden = true;
const remote = el("nip46-import");
if (remote) remote.hidden = true;
persistMode("anon");
syncConnectedPubkey(activeIdentity().pubkey);
refreshSignerState();
refreshIdentityState();
}
async function connectNip07Identity() {
setIdentityError(null);
if (!(window.nostr && typeof window.nostr.getPublicKey === "function")) {
setIdentityError("nip07_missing");
return;
}
try {
const pk = await window.nostr.getPublicKey();
if (!/^[0-9a-f]{64}$/i.test(String(pk || ""))) throw new Error("bad pubkey");
disconnectNip46();
nip07Pubkey = String(pk).toLowerCase();
identityMode = "nip07";
const imp = el("nsec-import");
if (imp) imp.hidden = true;
persistMode("nip07");
syncConnectedPubkey(nip07Pubkey);
refreshSignerState();
refreshIdentityState();
} catch (_) {
setIdentityError("nip07_missing");
}
}
function toggleNsecImport() {
setIdentityError(null);
showLockedRow(false);
const remote = el("nip46-import");
if (remote) remote.hidden = true;
const row = el("nsec-import");
if (!row) return;
row.hidden = !row.hidden;
if (!row.hidden) { const i = el("nsec-input"); if (i) i.focus(); }
}
async function submitNsecImport() {
setIdentityError(null);
const tools = nostrTools();
const input = el("nsec-input");
const raw = String((input && input.value) || "").trim();
let secretBytes;
let pubkey;
try {
const decoded = tools.nip19.decode(raw);
if (decoded.type !== "nsec") throw new Error("not an nsec");
secretBytes = decoded.data;
pubkey = tools.getPublicKey(secretBytes);
} catch (_) {
setIdentityError("invalid_nsec");
return;
}
const remember = Boolean(el("nsec-remember") && el("nsec-remember").checked);
if (remember) {
const pin = String((el("nsec-pin") && el("nsec-pin").value) || "");
if (!/^[0-9]{4,}$/.test(pin)) { setIdentityError("pin_short"); return; }
try {
const blob = await encryptSecretHex(bytesToHex(secretBytes), pin);
window.localStorage.setItem(IDENTITY_ENC_STORAGE_KEY, JSON.stringify(blob));
} catch (_) {
setIdentityError("invalid_nsec");
return;
}
} else {
try { window.localStorage.removeItem(IDENTITY_ENC_STORAGE_KEY); } catch (_) {}
}
disconnectNip46();
importedSecretHex = bytesToHex(secretBytes);
importedPubkey = String(pubkey).toLowerCase();
lockedNpub = npubFromHex(importedPubkey);
identityMode = "nsec";
if (input) input.value = "";
if (el("nsec-pin")) el("nsec-pin").value = "";
if (el("nsec-import")) el("nsec-import").hidden = true;
// Remembered keys persist mode "nsec" (locked on reload); session-only keys
// persist "anon" so a reload returns cleanly to anonymous, not a dead lock.
persistMode(remember ? "nsec" : "anon");
syncConnectedPubkey(importedPubkey);
refreshSignerState();
refreshIdentityState();
}
function unlockLockedUntil() {
try { return parseInt(window.localStorage.getItem(UNLOCK_LOCK_STORAGE_KEY) || "0", 10) || 0; } catch (_) { return 0; }
}
function setUnlockLock(untilMs) {
try {
if (untilMs) window.localStorage.setItem(UNLOCK_LOCK_STORAGE_KEY, String(untilMs));
else window.localStorage.removeItem(UNLOCK_LOCK_STORAGE_KEY);
} catch (_) {}
}
let unlockFails = 0;
async function unlockIdentity() {
setIdentityError(null);
const blob = getStoredEnc();
if (!blob) { showLockedRow(false); chooseAnonIdentity(); return; }
if (Date.now() < unlockLockedUntil()) { setIdentityError("too_many_attempts"); return; }
const pin = String((el("unlock-pin") && el("unlock-pin").value) || "");
let hex;
try {
hex = await decryptSecretHex(blob, pin);
} catch (_) {
unlockFails += 1;
if (unlockFails >= MAX_UNLOCK_FAILS) {
setUnlockLock(Date.now() + UNLOCK_COOLDOWN_MS);
unlockFails = 0;
setIdentityError("too_many_attempts");
} else {
setIdentityError("pin_wrong");
}
return;
}
unlockFails = 0;
setUnlockLock(0);
disconnectNip46();
importedSecretHex = hex;
importedPubkey = nostrTools().getPublicKey(hexToBytes(hex));
lockedNpub = npubFromHex(importedPubkey);
identityMode = "nsec";
if (el("unlock-pin")) el("unlock-pin").value = "";
showLockedRow(false);
persistMode("nsec");
syncConnectedPubkey(importedPubkey);
refreshSignerState();
refreshIdentityState();
}
function forgetStoredIdentity() {
try {
window.localStorage.removeItem(IDENTITY_ENC_STORAGE_KEY);
window.localStorage.removeItem(IDENTITY_NPUB_STORAGE_KEY);
} catch (_) {}
setUnlockLock(0);
unlockFails = 0;
importedSecretHex = null;
importedPubkey = null;
lockedNpub = null;
showLockedRow(false);
chooseAnonIdentity();
}
// Restore the chosen identity on load (called from the DOMContentLoaded init).
function restoreIdentity() {
let mode = "anon";
try { mode = window.localStorage.getItem(IDENTITY_MODE_STORAGE_KEY) || "anon"; } catch (_) {}
try { lockedNpub = window.localStorage.getItem(IDENTITY_NPUB_STORAGE_KEY) || null; } catch (_) {}
if (mode === "nip07") {
identityMode = "nip07";
if (window.nostr && typeof window.nostr.getPublicKey === "function") {
window.nostr.getPublicKey().then((pk) => {
if (/^[0-9a-f]{64}$/i.test(String(pk || ""))) {
nip07Pubkey = String(pk).toLowerCase();
refreshSignerState();
refreshIdentityState();
}
}).catch(() => {});
}
} else if (mode === "nsec" && getStoredEnc()) {
identityMode = "nsec"; // locked until the PIN is entered
showLockedRow(true);
} else {
identityMode = "anon";
}
refreshSignerState();
refreshIdentityState();
}
function refreshIdentityState() {
const state = el("identity-state");
const output = el("author-npub-output");
if (!state || !output) return;
try {
// nsec mode but no secret in memory yet → it is stored and locked.
if (identityMode === "nsec" && !importedPubkey) {
setLocalizedText("identity-state", "locked");
state.style.color = "#8a4d00";
if (lockedNpub) setRealOutput("author-npub-output", lockedNpub);
else setEmptyOutput("author-npub-output", "not_generated");
return;
}
if (identityMode === "anon" && !getStoredAnonSecret()) {
setLocalizedText("identity-state", "no_anon_npub");
state.style.color = "#8a4d00";
setEmptyOutput("author-npub-output", "not_generated");
return;
}
const id = activeIdentity();
const key = id.mode === "nip07" ? "signer_nip07"
: id.mode === "nsec" ? "signer_nsec"
: id.mode === "nip46" ? "signer_nip46"
: "anon_npub_ready";
setLocalizedText("identity-state", key);
state.style.color = "#1d6b3a";
setRealOutput("author-npub-output", id.npub);
updatePosterAddressOutputs();
} catch (error) {
state.textContent = error.message;
state.style.color = "#8a4d00";
}
}
function parseRelays(value) {
return value
.split(/\s+/)
.map((item) => item.trim())
.filter(Boolean)
.filter((item, index, list) => item.startsWith("wss://") && list.indexOf(item) === index);
}
function slugify(value) {
const slug = String(value || "voxvera")
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/^[-_]+|[-_]+$/g, "")
.slice(0, FIELD_LIMITS.folder_name);
return slug || "voxvera";
}
function escapeHtml(value) {
return String(value || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function redactionToHtml(value) {
return escapeHtml(value)
.replace(/~~(.*?)~~/g, '<span style="text-decoration: line-through;">$1</span>')
.replace(/\r?\n/g, "<br>");
}
function rawPayloadFromForm() {
const lang = supportedLang(el("field-lang").value || FALLBACK_LANG);
return {
type: "voxvera_flyer",
version: 1,
folder_name: slugify(el("field-folder-name").value),
lang,
name: el("field-name").value.trim(),
title: el("field-title").value.trim(),
subtitle: el("field-subtitle").value.trim(),
headline: el("field-headline").value.trim(),
content: el("field-content").value.trim(),
url_message: el("field-url-message").value.trim(),
url: el("field-url").value.trim(),
footer_message: el("field-footer-message").value.trim(),
attachment_path: "",
attachment_filename: "",
qr_target: "flyer_url"
};
}
function buildPayloadFromForm() {
const payload = rawPayloadFromForm();
validatePayload(payload);
return payload;
}
function validatePayload(payload) {
if (payload.type !== "voxvera_flyer") throw new Error("Payload type must be voxvera_flyer.");
if (payload.version !== 1) throw new Error("Payload version must be 1.");
Object.entries(FIELD_LIMITS).forEach(([key, limit]) => {
if (payload[key] && payload[key].length > limit) {
throw new Error(`${key} exceeds ${limit} characters.`);
}
});
Object.entries(payload).forEach(([key, value]) => {
const htmlCheckValue = typeof value === "string" ? value.replace(/<br\s*\/?>/gi, "") : value;
if (typeof value === "string" && key !== "url" && /<\s*\/?\s*[a-z][^>]*>|on[a-z]+\s*=/i.test(htmlCheckValue)) {
throw new Error(`${key} contains raw HTML.`);
}
});
["url", "tear_off_link"].forEach((key) => {
if (payload[key]) {
const parsed = new URL(payload[key]);
if (!["http:", "https:", "nostr:"].includes(parsed.protocol)) {
throw new Error(`Unsupported URL scheme: ${parsed.protocol}`);
}
}
});
}
// Untrusted fetched URLs: only http(s)/nostr may become a clickable href.
// Anything else (javascript:, data:, …) is blanked so it renders inert. The
// renderer escapes the rest of the content, so this is the one piece of
// sanitization the view path still needs.
function safeViewUrl(value) {
const raw = String(value || "").trim();
if (!raw) return "";
try {
const parsed = new URL(raw);
return ["http:", "https:", "nostr:"].includes(parsed.protocol) ? raw : "";
} catch (_) {
return "";
}
}
// Shape a payload for rendering. This is the VIEW path and is intentionally
// lenient: it does NOT run the strict authoring validation (length caps,
// raw-HTML rejection, version), because the renderer escapes every text field
// and unsafe URL schemes are neutralized below. Refusing to display an
// already-published flyer just because a field is over the authoring cap or
// contains "<"/">" is the bug that let the board show flyers the viewer could
// not open. Authoring still validates strictly via buildPayloadFromForm.
function normalizePayload(payload) {
const lang = supportedLang(payload.lang || FALLBACK_LANG);
return {
...CONFIG_DEFAULTS,
name: payload.name || CONFIG_DEFAULTS.name,
folder_name: slugify(payload.folder_name),
lang,
title: payload.title || CONFIG_DEFAULTS.title,
subtitle: payload.subtitle || CONFIG_DEFAULTS.subtitle,
headline: payload.headline || CONFIG_DEFAULTS.headline,
content: htmlBreaksToNewlines(payload.content || CONFIG_DEFAULTS.content),
url_message: payload.url_message || CONFIG_DEFAULTS.url_message,
url: payload.url ? safeViewUrl(payload.url) : CONFIG_DEFAULTS.url,
tear_off_link: payload.tear_off_link ? safeViewUrl(payload.tear_off_link) : CONFIG_DEFAULTS.tear_off_link,
footer_message: payload.footer_message || CONFIG_DEFAULTS.footer_message,
attachment_path: "",
attachment_filename: ""
};
}
function buildUnsignedEvent(payload) {
const folderName = slugify(payload.folder_name);
const lang = supportedLang(payload.lang || FALLBACK_LANG);
return {
kind: EVENT_KIND,
created_at: Math.floor(Date.now() / 1000),
tags: [
["d", `voxvera:${folderName}`],
["t", "voxvera"],
["t", "flyer"],
["title", payload.headline || payload.title || folderName],
["language", lang],
["L", "ISO-639-1"],
["l", lang, "ISO-639-1"]
],
content: JSON.stringify(payload)
};
}
function flyerIdentifier(payload) {
return `voxvera:${slugify(payload.folder_name)}`;
}
// Replaceable tombstone: same (kind, d) so it overwrites the flyer's content.
// Carries deleted:true + a ["deleted"] tag so the board/viewer hide it even if
// a relay ignores the NIP-09 deletion request.
function buildTombstoneEvent(payload) {
const folderName = slugify(payload.folder_name);
const lang = supportedLang(payload.lang || FALLBACK_LANG);
return {
kind: EVENT_KIND,
created_at: Math.floor(Date.now() / 1000),
tags: [
["d", `voxvera:${folderName}`],
["t", "voxvera"],
["t", "flyer"],
["deleted", ""],
["language", lang],
["L", "ISO-639-1"],
["l", lang, "ISO-639-1"]
],
content: JSON.stringify({ type: "voxvera_flyer", version: 1, deleted: true, folder_name: folderName, lang })
};
}
// NIP-09 deletion request (kind 5) for the addressable flyer, plus the
// specific event id when known.
function buildDeletionEvent(pubkeyHex, payload, eventId) {
const tags = [
["a", `${EVENT_KIND}:${pubkeyHex}:${flyerIdentifier(payload)}`],
["k", String(EVENT_KIND)]
];
if (/^[0-9a-f]{64}$/i.test(String(eventId || ""))) tags.push(["e", eventId]);
return { kind: 5, created_at: Math.floor(Date.now() / 1000), tags, content: "" };
}
// The naddr carries a single relay hint (the primary configured relay) rather
// than the full relay list. This keeps the poster URL short and its tear-off
// QR easy to scan (~171 chars vs ~233 for the old full-list URL) while still
// pointing a fresh viewer at a relay that has the event. Viewers also fall
// back to DEFAULT_RELAYS. Older relay-bearing naddr URLs still decode and
// resolve unchanged.
function buildNaddr(identity, payload, relays) {
const relayHints = Array.isArray(relays) ? relays.slice(0, 1) : [];
return nostrTools().nip19.naddrEncode({
identifier: flyerIdentifier(payload),
pubkey: identity.pubkey,
kind: EVENT_KIND,
relays: relayHints
});
}
function clientBaseUrl() {
return `${window.location.origin}${window.location.pathname}`;
}
function posterUrlForNaddr(naddr) {
// The naddr is carried in the URL fragment (#) rather than a ?addr= query
// param: it is shorter (less QR clutter), needs no URL-encoding (naddr is
// bech32, URL-safe), and never reaches the static host. The viewer reads
// both forms, so older ?addr= URLs and QR codes still resolve.
return `${clientBaseUrl()}#${naddr}`;
}
function withPosterUrl(payload, identity, relays) {
const naddr = buildNaddr(identity, payload, relays);
return {
payload: {
...payload,
tear_off_link: posterUrlForNaddr(naddr),
qr_target: "flyer_url"
},
naddr,
posterUrl: posterUrlForNaddr(naddr)
};
}
function updatePosterAddressOutputs() {
const naddrOutput = el("naddr-output");
const posterUrlOutput = el("poster-url-output");
if (!naddrOutput || !posterUrlOutput) return null;
try {
const relays = parseRelays(el("editor-relays").value);
const identity = activeIdentity();
const payload = buildPayloadFromForm();
const poster = withPosterUrl(payload, identity, relays);
setRealOutput("naddr-output", poster.naddr);
setRealOutput("poster-url-output", poster.posterUrl);
return poster;
} catch (_) {
return null;
}
}
async function signAndPublish(payload, relays) {
// Resolve the exact signing pubkey FIRST, then build the poster naddr from
// it, so the naddr can never point at a different author than the signer.
const pubkey = await resolveActivePubkey();
const identity = { pubkey, npub: npubFromHex(pubkey) };
const poster = withPosterUrl(payload, identity, relays);
const signed = await signActiveEvent(buildUnsignedEvent(poster.payload), pubkey);
if (!signed || !signed.id || !signed.sig) {
throw new Error("Signer returned an event without id/sig.");
}
// Guard against a signer that used a different key than the naddr was built
// from — publishing that would create an unresolvable poster URL.
if (String(signed.pubkey || "").toLowerCase() !== pubkey.toLowerCase()) {
throw new Error("Signer key does not match the poster address; not publishing.");
}
setRealOutput("author-npub-output", identity.npub);
setRealOutput("naddr-output", poster.naddr);
setRealOutput("poster-url-output", poster.posterUrl);
refreshIdentityState();
const results = await publishEvent(signed, relays);
return { event: signed, results, naddr: poster.naddr, posterUrl: poster.posterUrl, payload: poster.payload };
}
function publishEvent(event, relays) {
return Promise.all(relays.map((relay) => publishToRelay(event, relay)));
}
function publishToRelay(event, relay) {
return new Promise((resolve) => {
const ws = new WebSocket(relay);
const timeout = setTimeout(() => {
try { ws.close(); } catch (_) {}
resolve({ relay, ok: false, message: nostrLabel("timeout", currentUiLang()) });
}, 9000);
ws.onopen = () => ws.send(JSON.stringify(["EVENT", event]));
ws.onmessage = (message) => {
try {
const data = JSON.parse(message.data);
if (data[0] === "OK" && data[1] === event.id) {
clearTimeout(timeout);
ws.close();
resolve({ relay, ok: Boolean(data[2]), message: data[3] || "" });
}
} catch (_) {}
};
ws.onerror = () => {
clearTimeout(timeout);
resolve({ relay, ok: false, message: nostrLabel("connection_error", currentUiLang()) });
};
});
}
function fetchEvent(eventId, relays) {
return new Promise((resolve, reject) => {
const sub = `voxvera-${Math.random().toString(16).slice(2)}`;
let found = null;
let remaining = relays.length;
const sockets = relays.map((relay) => {
const ws = new WebSocket(relay);
ws.onopen = () => ws.send(JSON.stringify(["REQ", sub, { ids: [eventId], kinds: [EVENT_KIND], limit: 1 }]));
ws.onmessage = (message) => {
try {
const data = JSON.parse(message.data);
if (data[0] === "EVENT" && data[2] && data[2].id === eventId && !found) {
found = data[2];
sockets.forEach((socket) => {
try { socket.send(JSON.stringify(["CLOSE", sub])); socket.close(); } catch (_) {}
});
resolve(found);
}
} catch (_) {}
};
ws.onerror = () => {
remaining -= 1;
if (remaining <= 0 && !found) reject(new Error("No relay returned the event."));
};
return ws;
});
setTimeout(() => {
if (!found) {
sockets.forEach((socket) => {
try { socket.close(); } catch (_) {}
});
reject(new Error("Timed out fetching event."));
}
}, 12000);
});
}
function fetchReplaceable(address, relays) {
return new Promise((resolve, reject) => {
const sub = `voxvera-addr-${Math.random().toString(16).slice(2)}`;
let best = null;
let remaining = relays.length;
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
sockets.forEach((socket) => {
try { socket.send(JSON.stringify(["CLOSE", sub])); socket.close(); } catch (_) {}
});
if (best) {
resolve(best);
} else {
reject(new Error("No relay returned the addressed flyer."));
}
};
const consider = (event) => {
if (!event || event.pubkey !== address.pubkey || event.kind !== address.kind) return;
const hasMatchingD = Array.isArray(event.tags)
&& event.tags.some((tag) => tag[0] === "d" && tag[1] === address.identifier);
if (hasMatchingD && (!best || (event.created_at || 0) > (best.created_at || 0))) {
best = event;
}
};
// Query by author + kind and match the d-tag to the naddr identifier
// in-page, rather than relying on a relay-side "#d" filter. Some relays do
// not honor the #d filter for addressable events yet still return the event
// for a broader query — which is exactly how the bulletin board finds it.
// This makes the viewer resolve any flyer the board can list.
const sockets = relays.map((relay) => {
const ws = new WebSocket(relay);
ws.onopen = () => ws.send(JSON.stringify(["REQ", sub, {
authors: [address.pubkey],
kinds: [address.kind],
limit: 100
}]));
ws.onmessage = (message) => {
try {
const data = JSON.parse(message.data);
if (data[0] === "EVENT") {
consider(data[2]);
} else if (data[0] === "EOSE") {
remaining -= 1;
if (remaining <= 0) finish();
}
} catch (_) {}
};
ws.onerror = () => {
remaining -= 1;
if (remaining <= 0) finish();
};
return ws;
});
setTimeout(finish, 8000);
});
}
function parseEventReference(value) {
const raw = String(value || "").trim();
if (!raw) return null;
try {
const decoded = decodeURIComponent(raw);
// A 64-char hex event id is often copied with whitespace or a line break
// in the middle (e.g. the two-line event ID printed on a tear-off tab, or
// a label like "Event ID: ..."), so match against the input with inner
// whitespace removed.
const rawId = decoded.replace(/\s+/g, "").match(/\b[a-f0-9]{64}\b/i);
if (rawId) return { type: "event", id: rawId[0].toLowerCase(), relays: [] };
const nipMatch = decoded.match(/(?:nostr:)?((?:note1|nevent1|naddr1)[023456789acdefghjklmnpqrstuvwxyz]+)/i);
if (!nipMatch) return null;
const tools = nostrTools();
const result = tools.nip19.decode(nipMatch[1].toLowerCase());
if (result.type === "note") {
return { type: "event", id: result.data, relays: [] };
}
if (result.type === "nevent") {
return { type: "event", id: result.data.id, relays: result.data.relays || [] };
}
if (result.type === "naddr") {
return {
type: "address",
address: result.data,
relays: result.data.relays || []
};
}
} catch (_) {
return null;
}
return null;
}
function getUrlEventReference() {
const search = new URLSearchParams(window.location.search);
const params = ["addr", "naddr", "event", "id", "note", "nevent"];
for (const param of params) {
const value = search.get(param);
const parsed = parseEventReference(value);
if (parsed) return parsed;
}
const hash = window.location.hash.replace(/^#/, "");
const hashParts = hash.split(/[?&/]/).filter(Boolean);
for (const part of hashParts) {
const parsed = parseEventReference(part);
if (parsed) return parsed;
}
return parseEventReference(window.location.href);
}
function mergeRelayList(existingValue, extraRelays) {
return parseRelays(`${existingValue}\n${(extraRelays || []).join("\n")}`).join("\n");
}
function payloadFromEvent(event) {
if (!event || event.kind !== EVENT_KIND) throw new Error("Event kind is not a VoxVera source kind.");
const hasTag = (name, value) => Array.isArray(event.tags) && event.tags.some((tag) => tag[0] === name && tag[1] === value);
// Match the bulletin board's acceptance: a VoxVera flyer is any kind-30078
// event tagged t=voxvera whose content parses as a voxvera_flyer payload.
// (The t=flyer tag is no longer required, so a flyer the board lists always
// opens in the viewer.)
if (!hasTag("t", "voxvera")) throw new Error("Event is missing the VoxVera tag.");
const payload = JSON.parse(event.content);
if (!payload || payload.type !== "voxvera_flyer") throw new Error("Event is not a VoxVera flyer payload.");
const taggedLang = languageFromTags(event.tags);
if (!payload.lang && taggedLang) {
payload.lang = taggedLang;
}
// No strict validatePayload here — the flyer is rendered defensively
// (normalizePayload neutralizes unsafe URLs, renderPreview escapes all text),
// so we display what was published rather than refusing over authoring caps.
return payload;
}
// A deleted flyer is replaced (same d-tag) with a tombstone — payload.deleted
// or a ["deleted"] tag — so the viewer shows a "removed" state instead of the
// (now empty) content, even on relays that ignored the NIP-09 deletion.
function isFlyerTombstone(event, payload) {
if (payload && payload.deleted === true) return true;
const tags = (event && event.tags) || [];
return tags.some((tag) => tag[0] === "deleted");
}
function languageFromTags(tags) {
if (!Array.isArray(tags)) return "";
const languageTag = tags.find((tag) => Array.isArray(tag) && tag[0] === "language" && tag[1]);
const labelTag = tags.find((tag) => Array.isArray(tag) && tag[0] === "l" && tag[1]);
return supportedLang((languageTag && languageTag[1]) || (labelTag && labelTag[1]) || "");
}
function makeQrSvg(value) {
if (!value) return "";
if (typeof qrcode !== "function") {
return '<span class="qr-error">QR unavailable</span>';
}
try {
const qr = qrcode(0, "M");
qr.addData(value);
qr.make();
return qr.createSvgTag({
cellSize: 2,
margin: 2,
scalable: true
});
} catch (_) {
return '<span class="qr-error">QR too long</span>';
}
}
function renderStatusPreview(message, lang) {
const flyerLang = supportedLang(lang || FALLBACK_LANG);
const flyerLocale = localeData(flyerLang);
const dir = (flyerLocale.meta && flyerLocale.meta.direction) || "ltr";
const sheetClass = `container no-tear-offs ls-${trackingPolicy(flyerLang)}`;
el("flyer-preview").innerHTML = `
<div class="${sheetClass}" lang="${escapeHtml(flyerLang)}" dir="${escapeHtml(dir)}">
<div class="content">
<p class="flyer-status-message">${escapeHtml(message)}</p>
</div>
</div>
`;
updatePreviewScale();
}
function renderPreview(config) {
const flyerLang = supportedLang(config.lang || FALLBACK_LANG);
const flyerLocale = localeData(flyerLang);
const tearOff = config.tear_off_link || config.url || "";
const contentQr = config.url || tearOff;
const tearOffQrSvg = makeQrSvg(tearOff);
const contentQrSvg = makeQrSvg(contentQr);
const sheetClass = `${tearOff ? "container" : "container no-tear-offs"} ls-${trackingPolicy(flyerLang)}`;
const tabLinkLabel = nostrLabel("tab_link", flyerLang);
const builtWithLabel = nostrLabel("built_with", flyerLang);
const tabEventIdLabel = nostrLabel("tab_event_id", flyerLang);
const eventId = String(config.event_id || "").trim();
// The flyer's title labels each tab; it is auto-sized to fit after render.
const tabTitle = String(config.title || "").trim();
const tabTitleHtml = tabTitle
? `<div class="tear-off-title" dir="${escapeHtml((flyerLocale.meta && flyerLocale.meta.direction) || "ltr")}">${escapeHtml(tabTitle)}</div>`
: "";
const eventIdValueHtml = eventId.length > 32
? `${escapeHtml(eventId.slice(0, 32))}<br>${escapeHtml(eventId.slice(32))}`
: escapeHtml(eventId);
const eventIdHtml = eventId
? `<div class="tear-off-event-id">${escapeHtml(tabEventIdLabel)}:<br><span class="tear-off-event-id-value">${eventIdValueHtml}</span></div>`
: "";
const tearOffHtml = Array.from({ length: 10 }).map(() => `
<div class="tear-off">
<div class="tear-off-text">
${tabTitleHtml}
<a class="tear-off-link" href="${escapeHtml(tearOff)}" title="${escapeHtml(tearOff)}">${escapeHtml(tabLinkLabel)}</a>
${eventIdHtml}
</div>
<div class="qr-code" aria-label="QR code for ${escapeHtml(tearOff)}">${tearOffQrSvg}</div>
</div>
`).join("");
el("flyer-preview").innerHTML = `
<div class="${sheetClass}" lang="${escapeHtml(flyerLang)}" dir="${escapeHtml((flyerLocale.meta && flyerLocale.meta.direction) || "ltr")}">
<div class="left-tear-offs">${tearOffHtml}</div>
<div class="content">
<h1 data-fit-field="field-title">${redactionToHtml(config.title)}</h1>
<div class="distribute" data-fit-field="field-subtitle">${redactionToHtml(config.subtitle)}</div>
<h1 data-fit-field="field-headline">${redactionToHtml(config.headline)}</h1>
<hr>
<div class="message" data-fit-field="field-content">${redactionToHtml(config.content)}</div>
<div class="qr-code-body">
<div class="qr-code-url">
<span class="url-message" data-fit-field="field-url-message">${redactionToHtml(config.url_message)}</span><br><br>
<a href="${escapeHtml(config.url)}" target="_blank" rel="noopener noreferrer" data-fit-field="field-url">${escapeHtml(config.url)}</a>
</div>
<div class="qr-code main-qr" aria-label="QR code for ${escapeHtml(contentQr)}">${contentQrSvg}</div>
</div>
<hr>
<div class="footer">
<p class="binary" data-fit-field="field-footer-message">${redactionToHtml(config.footer_message)}</p>
<p class="credit">${escapeHtml(builtWithLabel)} <a href="https://github.com/PR0M3TH3AN/VoxVera">voxvera</a></p>
</div>
</div>
</div>
`;
fitTabTitles();
updatePreviewScale();
}
// Size the tear-off title as large as it can be while still fitting the tab
// (so short titles are big and long ones shrink instead of overflowing). All
// ten tabs share the same title, so measure one and apply the result to all.
function fitTabTitles() {
const titles = el("flyer-preview") && el("flyer-preview").querySelectorAll(".tear-off-title");
if (!titles || !titles.length) return;
const MAX = 22;
const MIN = 9;
const probe = titles[0];
let size = MAX;
probe.style.fontSize = `${size}px`;
// .tear-off-title caps its height via CSS; shrink until the text fits within
// both that height and the tab width.
const fits = () => probe.scrollHeight <= probe.clientHeight + 1 && probe.scrollWidth <= probe.clientWidth + 1;
while (size > MIN && !fits()) {
size -= 1;
probe.style.fontSize = `${size}px`;
}
titles.forEach((node) => { node.style.fontSize = `${size}px`; });
}
function updatePreviewScale() {
const preview = el("flyer-preview");
const sheet = preview && preview.querySelector(".container");
if (!preview || !sheet) return;
if (!window.matchMedia("(max-width: 900px)").matches) {
preview.style.removeProperty("--flyer-preview-scale");
preview.style.removeProperty("--flyer-preview-height");
preview.style.removeProperty("--flyer-preview-collapse");
return;
}
const availableWidth = Math.max(0, preview.clientWidth);
const sheetWidth = Math.max(1, sheet.offsetWidth);
const sheetHeight = Math.max(1, sheet.offsetHeight);
const scale = Math.min(1, Math.max(0.32, availableWidth / sheetWidth));
preview.style.setProperty("--flyer-preview-scale", scale.toFixed(4));
preview.style.setProperty("--flyer-preview-height", `${Math.ceil(sheetHeight * scale)}px`);
preview.style.setProperty("--flyer-preview-collapse", `${Math.ceil(sheetHeight * scale) - sheetHeight}px`);
}
function elementOverflows(node) {
if (!node) return false;
const tolerance = 1;
return node.scrollWidth > node.clientWidth + tolerance || node.scrollHeight > node.clientHeight + tolerance;
}
function fieldDisplayFits(fieldId) {
const selector = DISPLAY_FIT_TARGETS[fieldId];
if (!selector) return true;
const target = el("flyer-preview").querySelector(`[data-fit-field="${fieldId}"]`);
if (elementOverflows(target)) return false;
const sheet = el("flyer-preview").querySelector(".container");
return !elementOverflows(sheet);
}
function renderFormPreviewForEditing() {
const payload = rawPayloadFromForm();
const relays = parseRelays(el("editor-relays").value);
const identity = getOrCreateAnonIdentity(false);
const poster = withPosterUrl(payload, identity, relays);
setRealOutput("author-npub-output", identity.npub);
setRealOutput("naddr-output", poster.naddr);
setRealOutput("poster-url-output", poster.posterUrl);
renderPreview({
...CONFIG_DEFAULTS,
...poster.payload
});
}
function handleTextFieldInput(fieldId) {
const field = el(fieldId);
if (!field) return;
flyerSource = "custom";
loadedEventId = "";
renderFormPreviewForEditing();
if (!fieldDisplayFits(fieldId)) {
field.value = lastAcceptedFieldValues[fieldId] || "";
renderFormPreviewForEditing();
setFieldError(fieldId, nostrLabel("field_too_long", currentUiLang()).replace(/[::]\s*$/, ""));
return;
}
lastAcceptedFieldValues[fieldId] = field.value;
setFieldError(fieldId, "");
}
function formToPreview() {
const payload = buildPayloadFromForm();
const relays = parseRelays(el("editor-relays").value);
const identity = getOrCreateAnonIdentity(false);
const poster = withPosterUrl(payload, identity, relays);
setRealOutput("author-npub-output", identity.npub);
setRealOutput("naddr-output", poster.naddr);
setRealOutput("poster-url-output", poster.posterUrl);
refreshIdentityState();
syncUiLanguage(poster.payload.lang);
renderPreview(normalizePayload(poster.payload));
}
function setMode(mode) {
document.querySelectorAll(".tab").forEach((tab) => tab.classList.toggle("active", tab.dataset.mode === mode));
el("editor-panel").classList.toggle("active", mode === "editor");
el("viewer-panel").classList.toggle("active", mode === "viewer");
document.body.classList.toggle("mode-editor", mode === "editor");
document.body.classList.toggle("mode-viewer", mode === "viewer");
if (mode === "viewer") {
closeViewerDrawer();
}
if (!window.location.hash || window.location.hash === "#editor" || window.location.hash === "#viewer") {
history.replaceState(null, "", `#${mode}`);
}
window.requestAnimationFrame(updatePreviewScale);
}
function openViewerDrawer() {
el("viewer-panel").classList.add("drawer-open");
}
function closeViewerDrawer() {
el("viewer-panel").classList.remove("drawer-open");
}
function downloadJson(filename, data) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
// ---- Flyer design import / export (portable .json file) -------------------
// A design file is the flyer's content plus the relay list — deliberately
// identity-free (no npub, naddr, event id, or signature). Those are derived at
// publish time, so a design can be handed to anyone and published under THEIR
// key. Format: { type, version, flyer: <payload>, relays: [wss://…] }.
const DESIGN_FILE_TYPE = "voxvera_flyer_design";
const DESIGN_FILE_MAX_BYTES = 262144; // 256 KB — a design is tiny; reject blobs.
let pendingImport = null; // { flyer, relays } held while the confirm modal is open.
function exportDesign() {
// Export whatever is in the form (draft-friendly: no strict validation).
const file = {
type: DESIGN_FILE_TYPE,
version: 1,
flyer: rawPayloadFromForm(),
relays: parseRelays(el("editor-relays").value)
};
downloadJson(`${file.flyer.folder_name || "voxvera"}-voxvera-design.json`, file);
el("publish-status").textContent = nostrLabel("design_exported", currentUiLang());
}
// Pull the flyer payload + relays out of a parsed design file. Lenient: accepts
// the wrapper format and a bare voxvera_flyer payload (e.g. a copied config).
function readDesignFile(data) {
if (!data || typeof data !== "object") return null;
if (data.type === DESIGN_FILE_TYPE && data.flyer && typeof data.flyer === "object") {
const relays = Array.isArray(data.relays)
? data.relays.filter((r) => typeof r === "string" && /^wss:\/\//i.test(r))
: [];
return { flyer: data.flyer, relays };
}
if (data.type === "voxvera_flyer") {
return { flyer: data, relays: [] };
}
return null;
}
function openImportModal() {
const modal = el("import-modal");
if (modal) modal.hidden = false;
}
function closeImportModal() {
const modal = el("import-modal");
if (modal) modal.hidden = true;
}
// Step 1: read + parse + validate the picked file, then ask to confirm.
function importDesignFromFile(fileObj) {
if (!fileObj) return;
if (fileObj.size > DESIGN_FILE_MAX_BYTES) {
el("publish-status").textContent = nostrLabel("invalid_design_file", currentUiLang());
return;
}
const reader = new FileReader();
reader.onload = () => {
let parsed;
try { parsed = JSON.parse(String(reader.result || "")); } catch (_) { parsed = null; }
const design = readDesignFile(parsed);
if (!design) {
el("publish-status").textContent = nostrLabel("invalid_design_file", currentUiLang());
return;
}
pendingImport = design;
openImportModal();
};
reader.onerror = () => {
el("publish-status").textContent = nostrLabel("invalid_design_file", currentUiLang());
};
reader.readAsText(fileObj);
}
// Step 2: apply the held design after the user confirms the overwrite.
function applyPendingImport() {
if (!pendingImport) { closeImportModal(); return; }
const { flyer, relays } = pendingImport;
pendingImport = null;
closeImportModal();
// Imported content is untrusted; the lenient form-fill escapes on preview and
// strict validation still runs at publish (raw HTML / length caps / schemes).
fillEditorFromConfig(flyer);
if (relays.length) el("editor-relays").value = relays.join("\n");
flyerSource = "custom";
loadedEventConfig = null;
refreshLoadedFlyerActions();
renderPreview(normalizePayload(rawPayloadFromForm()));
el("publish-status").textContent = nostrLabel("design_imported", currentUiLang());
}
function renderRelayResults(results) {
return `<div class="relay-results">${results.map((item) => `
<div>
<span>${escapeHtml(item.relay)}:</span>
<span class="${item.ok ? "relay-ok" : "relay-error"}">${item.ok ? "OK" : escapeHtml(item.message || nostrLabel("failed", currentUiLang()))}</span>
</div>
`).join("")}</div>`;
}
async function copyTextFromElement(targetId, button) {
const value = el(targetId).textContent.trim();
const target = el(targetId);
if (!value || isEmptyOutput(target)) return;
await navigator.clipboard.writeText(value);
const oldText = button.textContent;
button.textContent = nostrLabel("copied", currentUiLang());
setTimeout(() => {
button.textContent = oldText;
}, 1200);
}
// Confirmation modal shown after a successful publish, so the author knows
// the flyer is live and can copy the URL to view/share it.
function openPublishModal(url, results) {
const modal = el("publish-modal");
const input = el("publish-modal-url");
if (!modal || !input) return;
input.value = url || "";
const copyBtn = el("publish-modal-copy");
if (copyBtn) copyBtn.textContent = nostrLabel("copy", currentUiLang());
// Show how many relays actually accepted the event so a weak publish is
// visible immediately (and flagged if zero/low).
const relaysNode = el("publish-modal-relays");
if (relaysNode) {
const list = Array.isArray(results) ? results : [];
const ok = list.filter((r) => r && r.ok).length;
const total = list.length;
if (total) {
relaysNode.textContent = `${nostrLabel("relays_accepted", currentUiLang())}: ${ok} / ${total}`;
relaysNode.classList.toggle("modal-relays-warn", ok === 0);
} else {
relaysNode.textContent = "";
}
}
modal.hidden = false;
input.focus();
input.select();
}
function closePublishModal() {
const modal = el("publish-modal");
if (modal) modal.hidden = true;
}
// ---- Loaded-flyer actions (re-publish / delete) -----------------------
// These act on a flyer loaded from an event, and only the author (the key
// that signed it) may use them.
function setLoadedFlyerStatus(message) {
const node = el("loaded-flyer-status");
if (!node) return;
node.textContent = message || "";
node.hidden = !message;
}
// Show the re-publish/delete controls only when a flyer loaded from an event
// is on screen. Author verification happens at click time (the active signing
// key must match), so the buttons appear whenever a fetched flyer is loaded.
function refreshLoadedFlyerActions() {
const group = el("loaded-flyer-actions");
if (!group) return;
const loaded = flyerSource === "event" && Boolean(loadedEventPubkey);
group.hidden = !loaded;
if (!loaded) setLoadedFlyerStatus("");
}
function openDeleteModal() {
const modal = el("delete-modal");
if (modal) modal.hidden = false;
}
function closeDeleteModal() {
const modal = el("delete-modal");
if (modal) modal.hidden = true;
}
// Re-publish (keep-alive): re-sign the loaded flyer's content under the same
// address and broadcast it so relays don't age it out. Same code path as
// publishing, so it also picks up any edits made in the form.
async function republishCurrentFlyer() {
setLoadedFlyerStatus("");
const pubkey = await resolveActivePubkey();
if (loadedEventPubkey && loadedEventPubkey.toLowerCase() !== pubkey.toLowerCase()) {
setLoadedFlyerStatus(nostrLabel("not_author", currentUiLang()));
return;
}
setLoadedFlyerStatus(nostrLabel("signing_publishing", currentUiLang()));
const payload = buildPayloadFromForm();
const relays = parseRelays(el("editor-relays").value);
const result = await signAndPublish(payload, relays);
loadedEventId = (result.event && result.event.id) || loadedEventId;
setLoadedFlyerStatus(nostrLabel("republished_done", currentUiLang()));
}
// Delete: publish a replaceable tombstone (overwrites the content) AND a
// NIP-09 deletion request. Belt and suspenders across relay behaviors.
async function deleteLoadedFlyer() {
setLoadedFlyerStatus("");
const pubkey = await resolveActivePubkey();
if (loadedEventPubkey && loadedEventPubkey.toLowerCase() !== pubkey.toLowerCase()) {
setLoadedFlyerStatus(nostrLabel("not_author", currentUiLang()));
return;
}
setLoadedFlyerStatus(nostrLabel("deleting", currentUiLang()));
const payload = loadedEventConfig || buildPayloadFromForm();
const relays = parseRelays(el("editor-relays").value);
// 1) Tombstone (replaceable overwrite), 2) NIP-09 deletion request.
const tombstone = await signActiveEvent(buildTombstoneEvent(payload), pubkey);
if (tombstone && String(tombstone.pubkey || "").toLowerCase() !== pubkey.toLowerCase()) {
throw new Error("Signer key does not match the flyer; not deleting.");
}
await publishEvent(tombstone, relays);
const deletion = await signActiveEvent(buildDeletionEvent(pubkey, payload, loadedEventId), pubkey);
await publishEvent(deletion, relays);
setLoadedFlyerStatus(nostrLabel("deleted_done", currentUiLang()));
// Reflect the removal in the preview.
renderStatusPreview(nostrLabel("flyer_removed", currentUiLang()), currentUiLang());
loadedEventConfig = null;
}
async function fetchAndRenderFromInput(eventInput, relaysValue, statusElement = el("viewer-status")) {
const parsed = parseEventReference(eventInput);
if (!parsed) throw new Error(nostrLabel("event_reference_required", currentUiLang()));
const relays = parseRelays(`${relaysValue}\n${(parsed.relays || []).join("\n")}`);
if (!relays.length) throw new Error(nostrLabel("relay_required", currentUiLang()));
el("viewer-event-id").value = eventInput;
el("viewer-relays").value = relays.join("\n");
statusElement.textContent = nostrLabel("fetching", currentUiLang());
const nostrEvent = parsed.type === "address"
? await fetchReplaceable(parsed.address, relays)
: await fetchEvent(parsed.id, relays);
const payload = payloadFromEvent(nostrEvent);
loadedEventPubkey = (nostrEvent && nostrEvent.pubkey) || "";
loadedEventId = (nostrEvent && nostrEvent.id) || "";
flyerSource = "event";
// A deleted flyer shows a "removed" state rather than rendering empty content.
if (isFlyerTombstone(nostrEvent, payload)) {
loadedEventConfig = null;
renderStatusPreview(nostrLabel("flyer_removed", currentUiLang()), currentUiLang());
statusElement.textContent = nostrLabel("flyer_removed", currentUiLang());
refreshLoadedFlyerActions();
closeViewerDrawer();
return;
}
const config = normalizePayload(payload);
loadedEventConfig = config;
fillEditorFromConfig(config);
el("viewer-config-output").value = JSON.stringify(config, null, 2);
renderPreview({ ...config, event_id: loadedEventId });
statusElement.textContent = `${nostrLabel("fetched", config.lang)} ${parsed.type === "address" ? parsed.address.identifier : parsed.id}`;
refreshLoadedFlyerActions();
closeViewerDrawer();
}
document.addEventListener("DOMContentLoaded", () => {
setDefaultText();
if (window.ResizeObserver) {
new ResizeObserver(updatePreviewScale).observe(el("flyer-preview"));
} else {
window.addEventListener("resize", updatePreviewScale);
}
const urlEvent = getUrlEventReference();
// `?edit=1` (used by the board's "Edit" link) opens the loaded flyer in the
// editor instead of the viewer, so the author lands on the form + actions.
const editIntent = new URLSearchParams(window.location.search).get("edit") === "1";
setMode(window.location.hash === "#editor" && !urlEvent ? "editor" : "viewer");
if (urlEvent) {
const urlInput = window.location.href;
const loadingLang = currentUiLang();
el("viewer-event-id").value = urlInput;
el("viewer-relays").value = mergeRelayList(el("viewer-relays").value, urlEvent.relays);
// A viewer arriving via an event-lookup URL should never see the default
// placeholder flyer. Show a localized loading state until the real event
// resolves (or a load-failed state if it does not).
renderStatusPreview(nostrLabel("loading_content", loadingLang), loadingLang);
fetchAndRenderFromInput(urlInput, el("viewer-relays").value).then(() => {
if (editIntent) setMode("editor");
}).catch((error) => {
renderStatusPreview(nostrLabel("load_failed", loadingLang), loadingLang);
el("viewer-status").textContent = error.message;
openViewerDrawer();
});
}
document.querySelectorAll(".tab").forEach((button) => {
button.addEventListener("click", () => setMode(button.dataset.mode));
});
el("publish-modal-close").addEventListener("click", closePublishModal);
el("publish-modal").addEventListener("click", (event) => {
if (event.target === el("publish-modal")) closePublishModal();
});
el("publish-modal-copy").addEventListener("click", async () => {
const input = el("publish-modal-url");
if (!input.value) return;
const button = el("publish-modal-copy");
try {
await navigator.clipboard.writeText(input.value);
button.textContent = nostrLabel("copied", currentUiLang());
setTimeout(() => { button.textContent = nostrLabel("copy", currentUiLang()); }, 1200);
} catch (_) {
input.focus();
input.select();
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") { closePublishModal(); closeDeleteModal(); }
});
LANGUAGE_SELECT_IDS.forEach((selectId) => {
const select = el(selectId);
if (!select) return;
select.addEventListener("change", () => applyLanguageChange(select.value || FALLBACK_LANG));
});
PAPER_SELECT_IDS.forEach((selectId) => {
const select = el(selectId);
if (!select) return;
select.addEventListener("change", () => applyPaperSize(select.value || DEFAULT_PAPER));
});
TEXT_FIELD_IDS.forEach((fieldId) => {
const field = el(fieldId);
if (!field) return;
field.addEventListener("input", () => handleTextFieldInput(fieldId));
});
el("open-viewer-controls").addEventListener("click", openViewerDrawer);
el("close-viewer-controls").addEventListener("click", closeViewerDrawer);
el("viewer-editor").addEventListener("click", () => {
if (loadedEventConfig) fillEditorFromConfig(loadedEventConfig);
setMode("editor");
});
el("viewer-print").addEventListener("click", printWithPageTitle);
document.querySelectorAll(".copy-button").forEach((button) => {
button.addEventListener("click", () => {
copyTextFromElement(button.dataset.copyTarget, button).catch((error) => {
el("publish-status").textContent = error.message;
});
});
});
el("generate-anon-identity").addEventListener("click", () => {
try {
const identity = getOrCreateAnonIdentity(true);
identityMode = "anon";
nip07Pubkey = null;
persistMode("anon");
syncConnectedPubkey(identity.pubkey);
setRealOutput("author-npub-output", identity.npub);
refreshSignerState();
refreshIdentityState();
el("publish-status").textContent = `${nostrLabel("npub_generated", currentUiLang())} ${identity.npub}`;
} catch (error) {
el("publish-status").textContent = error.message;
}
});
// Identity mode controls.
el("connect-nip07").addEventListener("click", () => {
connectNip07Identity().catch(() => setIdentityError("nip07_missing"));
});
el("use-nsec-toggle").addEventListener("click", toggleNsecImport);
el("nsec-remember").addEventListener("change", () => {
const pin = el("nsec-pin");
if (pin) pin.hidden = !el("nsec-remember").checked;
});
el("nsec-submit").addEventListener("click", () => {
submitNsecImport().catch(() => setIdentityError("invalid_nsec"));
});
el("nsec-input").addEventListener("keydown", (event) => {
if (event.key === "Enter") { event.preventDefault(); el("nsec-submit").click(); }
});
el("use-nip46-toggle").addEventListener("click", toggleNip46Import);
el("nip46-submit").addEventListener("click", () => {
submitNip46Import().catch(() => setIdentityError("nip46_failed"));
});
el("nip46-input").addEventListener("keydown", (event) => {
if (event.key === "Enter") { event.preventDefault(); el("nip46-submit").click(); }
});
el("unlock-key").addEventListener("click", () => {
unlockIdentity().catch(() => setIdentityError("pin_wrong"));
});
el("unlock-pin").addEventListener("keydown", (event) => {
if (event.key === "Enter") { event.preventDefault(); el("unlock-key").click(); }
});
el("forget-key").addEventListener("click", forgetStoredIdentity);
restoreIdentity();
el("preview-editor").addEventListener("click", () => {
try {
formToPreview();
el("publish-status").textContent = nostrLabel("preview_updated", currentUiLang());
} catch (error) {
el("publish-status").textContent = error.message;
}
});
el("export-design").addEventListener("click", () => {
try {
exportDesign();
} catch (error) {
el("publish-status").textContent = error.message;
}
});
el("import-design").addEventListener("click", () => {
const input = el("import-design-input");
if (input) { input.value = ""; input.click(); }
});
el("import-design-input").addEventListener("change", (event) => {
const file = event.target && event.target.files && event.target.files[0];
importDesignFromFile(file);
});
el("import-modal-cancel").addEventListener("click", () => {
pendingImport = null;
closeImportModal();
});
el("import-modal-confirm").addEventListener("click", applyPendingImport);
el("editor-form").addEventListener("submit", async (event) => {
event.preventDefault();
try {
const payload = buildPayloadFromForm();
const relays = parseRelays(el("editor-relays").value);
if (!relays.length) throw new Error(nostrLabel("relay_required", currentUiLang()));
el("publish-status").textContent = nostrLabel("signing_publishing", currentUiLang());
const result = await signAndPublish(payload, relays);
loadedEventId = result.event.id || "";
setRealOutput("event-id-output", result.event.id);
el("event-json-output").value = JSON.stringify(result.event, null, 2);
el("viewer-event-id").value = result.naddr;
el("publish-status").innerHTML = renderRelayResults(result.results);
renderPreview({ ...normalizePayload(result.payload), event_id: loadedEventId });
// The just-published flyer is now yours and loaded — enable re-publish/delete.
loadedEventPubkey = (result.event && result.event.pubkey) || "";
loadedEventConfig = normalizePayload(result.payload);
flyerSource = "event";
refreshLoadedFlyerActions();
openPublishModal(result.posterUrl, result.results);
} catch (error) {
el("publish-status").textContent = error.message;
}
});
el("republish-flyer").addEventListener("click", () => {
republishCurrentFlyer().catch((error) => setLoadedFlyerStatus(error.message));
});
el("delete-flyer").addEventListener("click", openDeleteModal);
el("delete-modal-cancel").addEventListener("click", closeDeleteModal);
el("delete-modal-confirm").addEventListener("click", () => {
closeDeleteModal();
deleteLoadedFlyer().catch((error) => setLoadedFlyerStatus(error.message));
});
el("viewer-form").addEventListener("submit", async (event) => {
event.preventDefault();
try {
await fetchAndRenderFromInput(el("viewer-event-id").value, el("viewer-relays").value);
} catch (error) {
el("viewer-status").textContent = error.message;
}
});
el("copy-viewer-config").addEventListener("click", async () => {
const value = el("viewer-config-output").value;
if (!value) return;
await navigator.clipboard.writeText(value);
el("viewer-status").textContent = nostrLabel("config_copied", currentUiLang());
});
el("print-preview").addEventListener("click", printWithPageTitle);
});
})();