Import/export a flyer design as a portable JSON file

Replaces the raw "Export event JSON" button with Import design / Export
design in the editor. A design file is content + relays only —
deliberately identity-free (no npub, naddr, event id, or signature, all
of which are derived at publish), so it can be handed to anyone and
published under their own key.

Format: { type: "voxvera_flyer_design", version: 1, flyer: <payload>,
relays: [] }. Export serializes the current form as-is (draft-friendly).
Import reads the picked file (capped at 256 KB), accepts the wrapper
format or a bare voxvera_flyer payload, then shows a confirm modal before
overwriting the editor; on confirm it fills the form and restores relays.
Imported content stays untrusted: the renderer escapes every field and
strict validation still runs at publish.

Adds 8 localized strings across all 14 languages (i18n parity passes).
Tests: export round-trip (identity-free assertions), import-then-confirm,
cancel-leaves-unchanged, and invalid-file rejection, across all 6
engines. Full suite 336 passing.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-11 13:42:43 -04:00
1 parent a4cb8dffcb
commit b344f92be7
5 files changed
+345 -13

No files matched your search

+1
View File
@@ -12,6 +12,7 @@ The previous Tor/OnionShare CLI version is preserved on the `legacy-tor` branch.
- **Anonymous by default, your identity if you want it.** The editor signs flyers under one of four identities: an **anonymous** per-device key (default, no setup), a **NIP-07 browser extension** (your secret never touches the page), an **imported `nsec`**, or a **NIP-46 remote signer** (paste a `bunker://` link — your secret stays in your signer app, the browser holds only an ephemeral key; session-only). An imported key signs in-memory for the session; tick "Remember on this device" to keep it, encrypted with a PIN (PBKDF2 → AES-GCM) — the plaintext secret is never stored. A short PIN is a convenience lock, not protection against a targeted attacker (see the [spec](docs/nostr-static-client-spec.md#publishing-identity)); the extension and remote-signer paths are stronger.
- **Publish confirmation.** After signing and publishing, a modal shows the new flyer's URL to copy, so authors know they're done and where to find it (localized, like everything else).
- **Edit, re-publish, and delete your flyers.** Because flyers are replaceable events, the author can edit one in place (same key + Flyer Name keeps the same URL), re-publish it as a keep-alive so relays don't age it out, or delete it. Delete publishes a replaceable **tombstone** (so VoxVera hides it even where relays ignore deletions) **and** a NIP-09 deletion request — best-effort, author-only.
- **Import / export a flyer design.** Save the current flyer as a portable `.json` file and load it back later — or hand it to someone else. The design file holds the content and relay list but **nothing identity-bound** (no npub, address, or signature; those are generated at publish), so a recipient can import it and publish under their own key. Importing asks for confirmation before replacing what's in the editor.
- **Open from a link or ID.** Load a flyer from an `naddr`, `nevent1`, `note1`, raw 64-char event ID, or `nostr:` URL. The lookup field tolerates copy/paste whitespace and newlines, so an event ID copied off a printed tear-off (where it wraps across two lines) still resolves.
- **Short, scannable poster URLs.** Each poster URL is a standard NIP-19 `naddr` carrying a single relay hint, placed in the URL `#fragment` (no `?addr=` query). Shorter URLs make the tear-off QR code easier to scan. Older relay-list `naddr`s and `?addr=` URLs still resolve.
- **Tear-off tabs.** The printable flyer has ten tear-off tabs. Each tab is labeled with the flyer's **title** (auto-sized to be as large as fits), a clickable **"Open & reprint this flyer"** link (live in the printed PDF), a QR code, and a **"Reopen at VoxVera.org — Event ID:"** line with the full event ID so anyone can re-find and reprint the flyer by typing it in. Localized in every language.
+25
View File
@@ -206,6 +206,31 @@ arriving later); the viewer shows a localized "This flyer was removed." state
replaceable overwrites and NIP-09 may still serve the original — so it is framed
as a request, not a guarantee.
## Importing and exporting a flyer design
The editor can save and load a flyer **design** as a portable `.json` file
(`Export design` / `Import design`), independent of publishing. The file format
is `{ type: "voxvera_flyer_design", version: 1, flyer: <payload>, relays: [] }`,
where `flyer` is exactly `rawPayloadFromForm()` — the content fields plus the
relay list, and **nothing identity-bound**: no npub, `naddr`, event id, or
signature (all of those are derived at publish time by `withPosterUrl` /
`signAndPublish`). That makes a design portable: it can be handed to anyone and
published under *their* key with a fresh address.
- **Export** (`exportDesign`) serializes the current form as-is (draft-friendly,
no strict validation) and downloads `<folder_name>-voxvera-design.json`.
- **Import** (`importDesignFromFile`) reads the picked file (capped at 256 KB),
`JSON.parse`s it, and accepts either the wrapper format or a bare
`voxvera_flyer` payload (`readDesignFile`, lenient). It then shows a **confirm
modal** (`#import-modal`) before overwriting the editor, and on confirm
(`applyPendingImport`) fills the form via `fillEditorFromConfig` and restores
the relay list. Imported content is untrusted, but it flows through the lenient
view path (the renderer escapes every field) and still faces strict
`validatePayload` at publish (raw-HTML rejection, length caps, URL schemes).
This replaced the earlier raw "Export event JSON" button; the published
(signed) event JSON is still shown in `#event-json-output` after a publish.
## Print paper size
The flyer supports two print sizes: **US Letter** (8.5×11in) and **A4**
+86
View File
@@ -740,6 +740,92 @@ test.describe("VoxVera static client", () => {
expect(errors, errors.join("\n")).toHaveLength(0);
});
test("exports the current flyer design as a portable, identity-free JSON file", async ({ page }) => {
await page.goto("/#editor");
// Wait for the editor to finish populating defaults before overwriting them
// (otherwise setDefaultText can race ahead of fill() on some engines).
await expect(page.locator("#flyer-preview .container").first()).toBeVisible();
await expect(page.locator("#field-url")).toHaveValue(/voxvera\.org/);
// Edit fields with no live auto-fit guard so the values reliably stick on
// every engine (the printable-fit guard on title/url can revert text whose
// rendering overflows the sheet, which is engine/font-metric dependent).
await page.locator("#field-name").fill("Campaign Alpha");
await page.locator("#field-folder-name").fill("alpha-campaign");
await page.locator("#editor-relays").fill("wss://relay.example.com");
const [download] = await Promise.all([
page.waitForEvent("download"),
page.locator("#export-design").click()
]);
const data = JSON.parse(require("fs").readFileSync(await download.path(), "utf8"));
expect(data.type).toBe("voxvera_flyer_design");
expect(data.flyer.name).toBe("Campaign Alpha");
expect(data.flyer.folder_name).toBe("alpha-campaign");
expect(data.relays).toContain("wss://relay.example.com");
// The url field is captured faithfully, whatever the form currently holds.
expect(data.flyer.url).toBe(await page.locator("#field-url").inputValue());
// Identity-free: no naddr, event id, pubkey, or signature is baked in.
const blob = JSON.stringify(data);
expect(blob).not.toContain("naddr");
expect(blob).not.toContain("\"sig\"");
expect(blob).not.toContain("\"pubkey\"");
await expect(page.locator("#publish-status")).toHaveText("Design exported.");
});
test("imports a design file and replaces the editor after confirming", async ({ page }) => {
const errors = trackErrors(page);
await page.goto("/#editor");
const design = {
type: "voxvera_flyer_design",
version: 1,
flyer: {
type: "voxvera_flyer", version: 1, folder_name: "imported", lang: "en",
name: "Imported Name", title: "Imported Title", subtitle: "", headline: "",
content: "Imported body", url_message: "", url: "https://example.org/imported",
footer_message: "", attachment_path: "", attachment_filename: "", qr_target: "flyer_url"
},
relays: ["wss://imported.example.com"]
};
await page.setInputFiles("#import-design-input", {
name: "design.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(design))
});
// A confirm step guards against clobbering the current draft.
await expect(page.locator("#import-modal")).toBeVisible();
await page.locator("#import-modal-confirm").click();
await expect(page.locator("#import-modal")).toBeHidden();
await expect(page.locator("#field-title")).toHaveValue("Imported Title");
await expect(page.locator("#field-name")).toHaveValue("Imported Name");
await expect(page.locator("#field-content")).toHaveValue("Imported body");
await expect(page.locator("#editor-relays")).toHaveValue("wss://imported.example.com");
await expect(page.locator("#publish-status")).toHaveText("Design imported.");
expect(errors, errors.join("\n")).toHaveLength(0);
});
test("cancelling the import confirm leaves the editor unchanged", async ({ page }) => {
await page.goto("/#editor");
await page.locator("#field-title").fill("Keep Me");
await page.setInputFiles("#import-design-input", {
name: "design.json", mimeType: "application/json",
buffer: Buffer.from(JSON.stringify({
type: "voxvera_flyer_design", version: 1,
flyer: { type: "voxvera_flyer", version: 1, title: "Should Not Apply", lang: "en" },
relays: []
}))
});
await expect(page.locator("#import-modal")).toBeVisible();
await page.locator("#import-modal-cancel").click();
await expect(page.locator("#import-modal")).toBeHidden();
await expect(page.locator("#field-title")).toHaveValue("Keep Me");
});
test("rejects a file that isn't a valid VoxVera design", async ({ page }) => {
await page.goto("/#editor");
await page.setInputFiles("#import-design-input", {
name: "notes.json", mimeType: "application/json", buffer: Buffer.from('{"hello":"world"}')
});
await expect(page.locator("#import-modal")).toBeHidden();
await expect(page.locator("#publish-status")).toHaveText("That file isn't a valid VoxVera design.");
});
test("editor can connect a NIP-07 identity and publish under it", async ({ page }) => {
const PK = "a".repeat(64);
await page.addInitScript((pk) => {
+16 -1
View File
@@ -142,9 +142,13 @@
</label>
<div class="actions">
<button type="button" id="preview-editor">Preview</button>
<button type="button" id="export-event">Export event JSON</button>
<button type="button" id="import-design">Import design</button>
<button type="button" id="export-design">Export design</button>
<button type="submit" id="publish-event">Sign and publish</button>
</div>
<!-- Hidden picker for "Import design"; the design file is content +
relays only (no identity/naddr), so it is portable across keys. -->
<input type="file" id="import-design-input" accept="application/json,.json" hidden>
<!-- Shown only when a flyer you authored is loaded from an event. -->
<div id="loaded-flyer-actions" class="actions" hidden>
<button type="button" id="republish-flyer" class="secondary-button" data-i18n-key="republish">Re-publish to relays</button>
@@ -235,6 +239,17 @@
</div>
</div>
<div id="import-modal" class="modal-overlay" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="import-modal-title">
<h2 id="import-modal-title" data-i18n-key="import_confirm_title">Import this design?</h2>
<p id="import-modal-message" data-i18n-key="import_confirm_message">This replaces the flyer currently in the editor.</p>
<div class="modal-actions">
<button type="button" id="import-modal-cancel" class="secondary-button" data-i18n-key="cancel">Cancel</button>
<button type="button" id="import-modal-confirm" class="secondary-button" data-i18n-key="import_button">Import</button>
</div>
</div>
</div>
<script src="locales.js"></script>
<script src="vendor/qrcode-generator/qrcode.js"></script>
<script src="vendor/nostr-tools/nostr.bundle.js"></script>
+217 -12
View File
@@ -119,7 +119,8 @@ Join us in a revolution that values truth and transparency. Together, we can bui
};
const BUTTON_LABELS = {
"preview-editor": "preview",
"export-event": "export_event",
"import-design": "import_design",
"export-design": "export_design",
"publish-event": "publish_event",
"copy-viewer-config": "copy_config",
"print-preview": "print_preview",
@@ -260,6 +261,14 @@ Join us in a revolution that values truth and transparency. Together, we can bui
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 مجهول:",
@@ -362,6 +371,14 @@ Join us in a revolution that values truth and transparency. Together, we can bui
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:",
@@ -487,6 +504,14 @@ Join us in a revolution that values truth and transparency. Together, we can bui
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",
@@ -566,6 +591,14 @@ Join us in a revolution that values truth and transparency. Together, we can bui
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:",
@@ -668,6 +701,14 @@ Join us in a revolution that values truth and transparency. Together, we can bui
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 ناشناس ساخته شد:",
@@ -770,6 +811,14 @@ Join us in a revolution that values truth and transparency. Together, we can bui
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 :",
@@ -872,6 +921,14 @@ Join us in a revolution that values truth and transparency. Together, we can bui
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 אנונימי נוצר:",
@@ -974,6 +1031,14 @@ Join us in a revolution that values truth and transparency. Together, we can bui
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 बनाया गया:",
@@ -1076,6 +1141,14 @@ Join us in a revolution that values truth and transparency. Together, we can bui
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を生成しました:",
@@ -1178,6 +1251,14 @@ Join us in a revolution that values truth and transparency. Together, we can bui
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:",
@@ -1280,6 +1361,14 @@ Join us in a revolution that values truth and transparency. Together, we can bui
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 создан:",
@@ -1382,6 +1471,14 @@ Join us in a revolution that values truth and transparency. Together, we can bui
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:",
@@ -1484,6 +1581,14 @@ Join us in a revolution that values truth and transparency. Together, we can bui
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:",
@@ -1586,6 +1691,14 @@ Join us in a revolution that values truth and transparency. Together, we can bui
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:",
@@ -3386,6 +3499,94 @@ Join us in a revolution that values truth and transparency. Together, we can bui
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>
@@ -3693,22 +3894,26 @@ Join us in a revolution that values truth and transparency. Together, we can bui
}
});
el("export-event").addEventListener("click", () => {
el("export-design").addEventListener("click", () => {
try {
const payload = buildPayloadFromForm();
const relays = parseRelays(el("editor-relays").value);
const identity = activeIdentity();
const poster = withPosterUrl(payload, identity, relays);
const event = buildUnsignedEvent(poster.payload);
setRealOutput("author-npub-output", identity.npub);
setRealOutput("naddr-output", poster.naddr);
setRealOutput("poster-url-output", poster.posterUrl);
el("event-json-output").value = JSON.stringify(event, null, 2);
downloadJson(`${poster.payload.folder_name}-voxvera-event-unsigned.json`, event);
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();