Localize VoxVera Nostr client

This commit is contained in:
thePR0M3TH3AN committed 2026-06-04 22:06:22 -04:00
1 parent d415eb26a3
commit 46fa306c31
3 files changed
+190 -27

No files matched your search

+21 -20
View File
@@ -1,6 +1,6 @@
<!DOCTYPE html>
<html lang="en">
<head>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VoxVera Nostr Client</title>
@@ -13,8 +13,8 @@
<p>Static editor and viewer for decentralized flyer source events.</p>
</div>
<nav class="mode-tabs" aria-label="Client mode">
<button class="tab active" type="button" data-mode="editor">Editor</button>
<button class="tab" type="button" data-mode="viewer">Viewer</button>
<button id="editor-tab" class="tab active" type="button" data-mode="editor">Editor</button>
<button id="viewer-tab" class="tab" type="button" data-mode="viewer">Viewer</button>
</nav>
</header>
@@ -34,7 +34,7 @@
</div>
<label>
Relays
<span id="label-relays">Relays</span>
<textarea id="editor-relays" rows="3" spellcheck="false"></textarea>
</label>
<div class="identity-row">
@@ -44,11 +44,11 @@
<div class="field-grid">
<label>
Flyer Name
<span id="label-flyer-name">Flyer Name</span>
<input id="field-folder-name" name="folder_name" maxlength="64" value="voxvera">
</label>
<label>
Language
<span id="label-language">Language</span>
<select id="field-lang" name="lang">
<option value="en">en</option>
<option value="es">es</option>
@@ -69,35 +69,35 @@
</div>
<label>
Page title
<span id="label-page-title">Page title</span>
<input id="field-name" name="name" maxlength="120" value="Vox Vera Printable Flyers">
</label>
<label>
Title
<span id="label-title">Title</span>
<input id="field-title" name="title" maxlength="80" value="TOP SECRET">
</label>
<label>
Subtitle
<span id="label-subtitle">Subtitle</span>
<input id="field-subtitle" name="subtitle" maxlength="120" value="DO ~~NOT~~ DISTRIBUTE">
</label>
<label>
Headline
<span id="label-headline">Headline</span>
<input id="field-headline" name="headline" maxlength="160" value="OPERATION VOX VERA">
</label>
<label>
Content
<span id="label-content">Content</span>
<textarea id="field-content" name="content" rows="9" maxlength="10000"></textarea>
</label>
<label>
URL message
<span id="label-url-message">URL message</span>
<input id="field-url-message" name="url_message" maxlength="240" value="Follow this link to learn more.">
</label>
<label>
Poster URL
<span id="label-poster-url">Poster URL</span>
<input id="field-url" name="url" maxlength="2048" value="https://voxvera.org/" readonly>
</label>
<label>
Footer message
<span id="label-footer-message">Footer message</span>
<input id="field-footer-message" name="footer_message" maxlength="240" value="0110010 0101011 0110010 0111101 0110100">
</label>
@@ -109,7 +109,7 @@
</form>
<aside class="result-panel">
<h2>Published Event</h2>
<h2 id="published-event-heading">Published Event</h2>
<dl class="result-list">
<dt>Event ID</dt>
<dd class="copy-value"><code id="event-id-output">Not published</code><button type="button" class="copy-button" data-copy-target="event-id-output">Copy</button></dd>
@@ -135,22 +135,22 @@
<button type="button" id="close-viewer-controls" class="secondary-button">Close</button>
</div>
<label>
Event ID
<span id="label-event-id">Event ID</span>
<input id="viewer-event-id" spellcheck="false" placeholder="naddr, event id, note1, nevent1, or nostr: URL">
</label>
<label>
Relays
<span id="label-viewer-relays">Relays</span>
<textarea id="viewer-relays" rows="3" spellcheck="false"></textarea>
</label>
<div class="actions">
<button type="submit">Fetch and render</button>
<button id="viewer-fetch-render" type="submit">Fetch and render</button>
<button type="button" id="copy-viewer-config">Copy config JSON</button>
</div>
<p id="viewer-status" class="status-line">Idle</p>
</form>
<aside class="result-panel">
<h2>Normalized Config</h2>
<h2 id="normalized-config-heading">Normalized Config</h2>
<textarea id="viewer-config-output" rows="16" spellcheck="false" readonly></textarea>
</aside>
</div>
@@ -158,13 +158,14 @@
<section class="preview-band" aria-label="Flyer preview">
<div class="preview-toolbar">
<h2>Preview</h2>
<h2 id="preview-heading">Preview</h2>
<button type="button" id="print-preview">Print preview</button>
</div>
<div id="flyer-preview" class="flyer-preview"></div>
</section>
</main>
<script src="locales.js"></script>
<script src="vendor/qrcode-generator/qrcode.js"></script>
<script src="vendor/nostr-tools/nostr.bundle.js"></script>
<script src="nostr-client.js"></script>
+1
View File
@@ -0,0 +1 @@
window.VoxVeraLocales = {"ar":{"meta":{"language_name":"العربية","flag":"🇸🇦","direction":"rtl"},"cli":{"dep_check_header":"فحص التبعيات","dep_found":"موجود","dep_missing":"مفقود","all_deps_installed":"تم تثبيت جميع التبعيات.","missing_deps_error":"بعض التبعيات مفقودة. تشغيل: pip install 'voxvera[all]'","init_name":"الاسم","init_folder":"اسم المجلد","init_title":"العنوان","init_subtitle":"العنوان الفرعي","init_headline":"العنوان الرئيسي","init_body":"نص الرسالة","init_links":"الروابط","edit_again":"تعديل مرة أخرى؟","body_length":"طول النص","body_limit_exceeded":"يتجاوز الحد","url_label":"URL (رابط المحتوى - مورد خارجي)","url_message_label":"رسالة الـ URL","footer_message_label":"رسالة التذييل","template_not_found":"القالب {name} غير موجود","template_copied":"تم نسخ القالب إلى {dest}","flyer_created":"تم إنشاء ملفات المنشور تحت {dest}","dir_not_found":"المجلد {dir_path} غير موجود","onionshare_error":"خرج OnionShare لـ {folder_name} بشكل غير متوقع.","onionshare_causes":"الأسباب الشائعة: الشبكة محظورة، وقت النظام غير دقيق، أو OnionShare يعمل بالفعل.","onionshare_logs_tip":"راجع {logfile} للحصول على السجلات الكاملة.","onion_url_msg":"[{folder_name}] رابط Onion: {onion_url}","onionshare_running_pid":"[{folder_name}] OnionShare يعمل (PID {pid}).","export_not_found":"خطأ: مجلد الموقع '{folder_name}' غير موجود في {host_root}","export_success":"تم تصدير '{folder_name}' إلى {output_path}","export_all_none":"لم يتم العثور على مواقع لتصديرها.","export_all_starting":"جاري تصدير {count} مواقع إلى {dir}...","export_all_complete":"اكتمل تصدير الكل.","import_zip_not_found":"خطأ: ملف Zip '{path}' غير موجود.","import_invalid_config":"خطأ: ملف تصدير غير صالح. 'config.json' مفقود.","import_invalid_folder":"خطأ: config.json غير صالح. 'folder_name' مفقود.","import_overwrite_confirm":"الموقع '{folder_name}' موجود بالفعل. هل تريد الاستبدال؟","import_cancelled":"تم إلغاء الاستيراد.","import_success":"تم استيراد '{folder_name}' بنجاح إلى {dest_dir}","import_dir_not_found":"خطأ: المجلد '{dir}' غير موجود.","import_multiple_none":"لم يتم العثور على ملفات zip في {dir}","import_multiple_found":"تم العثور على {count} موقع للاستيراد من {dir}...","importing_file":"جاري استيراد {name}...","import_multiple_complete":"اكتمل استيراد الكل.","batch_import_none":"لم يتم العثور على ملفات JSON في الواردات","batch_import_processing":"جاري معالجة {file}","stop_server_not_running":"الخادم {folder_name} لا يعمل.","stop_server_success":"تم إيقاف {folder_name} (PID {pid})","delete_server_success":"تم حذف الخادم {folder_name}","manage_select_server":"اختر خادماً لإدارته (أو خروج):","manage_create_new":"--- إنشاء موقع/منشور جديد ---","manage_start_all":"--- تشغيل جميع المواقع ---","manage_stop_all":"--- إيقاف جميع المواقع ---","manage_export_all":"--- تصدير جميع المواقع ---","manage_import_multiple":"--- استيراد مواقع متعددة ---","manage_exit":"خروج","manage_startup_summary":"ملخص التشغيل","manage_press_enter":"اضغط Enter للمتابعة","manage_action_menu":"إدارة {name}:","manage_action_toggle_stop":"إيقاف","manage_action_toggle_start":"تشغيل","manage_action_export":"تصدير إلى Zip","manage_action_delete":"حذف","manage_action_back":"رجوع","manage_delete_confirm":"هل أنت متأكد أنك تريد حذف {name}؟","bootstrapping_msg":"[{name}] جاري الاتصال بمرحلات Tor وإعداد الخدمة المخفية...","bootstrapping_progress":"[{name}] تمهيد Tor: {progress}% ...","no_urls_qr_skip":"لم يتم تكوين أي روابط بعد. سيتم إنشاء أكواد QR بعد 'voxvera serve'","input_file_not_found":"ملف الإدخال {path} غير موجود.","attachment_path_label":"مسار ملف المرفق (اختياري - سيتم تقديمه من موقع Tor)","manage_action_edit":"تحرير","manage_templates":"--- إنشاء من قالب ---","template_select":"اختر قالباً:","body_over_limit_warning":"تحذير: نص المحتوى يتجاوز 1000 حرف. قد يتم قطعه في المنشور.","manage_action_export_keys":"تصدير المفاتيح","manage_action_import_keys":"استيراد المLine truncated
+168 -7
View File
@@ -7,6 +7,9 @@
"wss://relay.primal.net"
];
const ANON_SECRET_STORAGE_KEY = "voxvera_nostr_anon_secret_hex";
const UI_LANG_STORAGE_KEY = "voxvera_nostr_lang";
const LOCALES = window.VoxVeraLocales || {};
const FALLBACK_LANG = "en";
const EVENT_KIND = 30078;
const FIELD_LIMITS = {
folder_name: 64,
@@ -40,19 +43,164 @@ Join us in a revolution that values truth and transparency. Together, we can bui
attachment_path: "",
attachment_filename: ""
};
const UI_LABELS = {
"label-relays": "cli.init_links",
"label-flyer-name": "cli.init_folder",
"label-page-title": "cli.init_name",
"label-title": "cli.init_title",
"label-subtitle": "cli.init_subtitle",
"label-headline": "cli.init_headline",
"label-content": "cli.init_body",
"label-url-message": "cli.url_message_label",
"label-poster-url": "cli.url_label",
"label-footer-message": "cli.footer_message_label"
};
const BUTTON_LABELS = {
"preview-editor": "Preview",
"export-event": "Export event JSON",
"publish-event": "Sign and publish",
"copy-viewer-config": "Copy config JSON",
"print-preview": "Print preview",
"open-viewer-controls": "Load Event",
"viewer-print": "Print",
"viewer-editor": "Editor",
"close-viewer-controls": "Close",
"generate-anon-identity": "Generate anonymous npub",
"viewer-fetch-render": "Fetch and render"
};
const HEADING_LABELS = {
"editor-heading": "Editor",
"viewer-heading": "Viewer",
"published-event-heading": "Published Event",
"normalized-config-heading": "Normalized Config",
"preview-heading": "Preview"
};
const el = (id) => document.getElementById(id);
function setDefaultText() {
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 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: 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 select = el("field-lang");
if (!select) return;
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);
});
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";
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(HEADING_LABELS).forEach(([id, value]) => setText(id, value));
Object.entries(BUTTON_LABELS).forEach(([id, value]) => setButtonText(id, value));
setButtonText("print-preview", translate("web.print_button", selected) || BUTTON_LABELS["print-preview"]);
setButtonText("viewer-print", translate("web.print_button", selected) || BUTTON_LABELS["viewer-print"]);
setButtonText("viewer-editor", translate("cli.manage_action_edit", selected) || BUTTON_LABELS["viewer-editor"]);
setButtonText("close-viewer-controls", translate("cli.manage_action_back", selected) || BUTTON_LABELS["close-viewer-controls"]);
const select = el("field-lang");
if (select) select.value = selected;
}
function setDefaultText(lang) {
const relayText = DEFAULT_RELAYS.join("\n");
el("editor-relays").value = relayText;
el("viewer-relays").value = relayText;
el("field-content").value = CONFIG_DEFAULTS.content;
renderPreview(CONFIG_DEFAULTS);
populateLanguageOptions();
const defaultLang = supportedLang(lang || getStoredUiLang() || navigator.language.split("-")[0]);
applyFlyerDefaults(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 "";
}
}
function applyFlyerDefaults(lang) {
const defaults = localeDefaults(lang);
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);
}
function refreshSignerState() {
const state = el("signer-state");
if (window.nostr && typeof window.nostr.signEvent === "function") {
@@ -168,11 +316,12 @@ Join us in a revolution that values truth and transparency. Together, we can bui
}
function buildPayloadFromForm() {
const lang = supportedLang(el("field-lang").value || FALLBACK_LANG);
const payload = {
type: "voxvera_flyer",
version: 1,
folder_name: slugify(el("field-folder-name").value),
lang: el("field-lang").value || "en",
lang,
name: el("field-name").value.trim(),
title: el("field-title").value.trim(),
subtitle: el("field-subtitle").value.trim(),
@@ -212,11 +361,12 @@ Join us in a revolution that values truth and transparency. Together, we can bui
function normalizePayload(payload) {
validatePayload(payload);
const lang = supportedLang(payload.lang || FALLBACK_LANG);
return {
...CONFIG_DEFAULTS,
name: payload.name || CONFIG_DEFAULTS.name,
folder_name: slugify(payload.folder_name),
lang: payload.lang || "en",
lang,
title: payload.title || CONFIG_DEFAULTS.title,
subtitle: payload.subtitle || CONFIG_DEFAULTS.subtitle,
headline: payload.headline || CONFIG_DEFAULTS.headline,
@@ -517,6 +667,8 @@ Join us in a revolution that values truth and transparency. Together, we can bui
}
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);
@@ -533,7 +685,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
</div>
`).join("");
el("flyer-preview").innerHTML = `
<div class="${sheetClass}">
<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>${redactionToHtml(config.title)}</h1>
@@ -568,6 +720,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
el("poster-url-output").textContent = poster.posterUrl;
el("field-url").value = poster.posterUrl;
refreshIdentityState();
syncUiLanguage(poster.payload.lang);
renderPreview(normalizePayload(poster.payload));
}
@@ -636,6 +789,8 @@ Join us in a revolution that values truth and transparency. Together, we can bui
: await fetchEvent(parsed.id, relays);
const payload = payloadFromEvent(nostrEvent);
const config = normalizePayload(payload);
syncUiLanguage(config.lang);
el("field-lang").value = config.lang;
el("viewer-config-output").value = JSON.stringify(config, null, 2);
renderPreview(config);
statusElement.textContent = `Fetched ${parsed.type === "address" ? parsed.address.identifier : parsed.id}`;
@@ -643,7 +798,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
}
document.addEventListener("DOMContentLoaded", () => {
setDefaultText();
setDefaultText(getStoredUiLang() || navigator.language.split("-")[0]);
const urlEvent = getUrlEventReference();
setMode(window.location.hash === "#editor" && !urlEvent ? "editor" : "viewer");
if (urlEvent) {
@@ -660,6 +815,12 @@ Join us in a revolution that values truth and transparency. Together, we can bui
button.addEventListener("click", () => setMode(button.dataset.mode));
});
el("field-lang").addEventListener("change", () => {
const lang = supportedLang(el("field-lang").value || FALLBACK_LANG);
applyFlyerDefaults(lang);
renderPreview(buildPayloadFromForm());
});
el("open-viewer-controls").addEventListener("click", openViewerDrawer);
el("close-viewer-controls").addEventListener("click", closeViewerDrawer);
el("viewer-editor").addEventListener("click", () => setMode("editor"));