mirror of
https://github.com/PR0M3TH3AN/VoxVera.git
synced 2026-10-27 09:40:40 +00:00
Localize VoxVera Nostr client
This commit is contained in:
1 parent
d415eb26a3
commit
46fa306c31
3 files changed
+190
-27
No files matched your search
+21
-20
@@ -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>
|
||||
|
||||
@@ -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
@@ -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"));
|
||||
|
||||
Reference in new issue
Block a user