diff --git a/README.md b/README.md index 82405c3..6dbfe41 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/nostr-static-client-spec.md b/docs/nostr-static-client-spec.md index 78a3f63..b230381 100644 --- a/docs/nostr-static-client-spec.md +++ b/docs/nostr-static-client-spec.md @@ -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: , 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 `-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** diff --git a/e2e/flyer.spec.js b/e2e/flyer.spec.js index f09063b..efeb112 100644 --- a/e2e/flyer.spec.js +++ b/e2e/flyer.spec.js @@ -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) => { diff --git a/site/index.html b/site/index.html index e002b3f..6e0731c 100644 --- a/site/index.html +++ b/site/index.html @@ -142,9 +142,13 @@
- + +
+ + + + diff --git a/site/nostr-client.js b/site/nostr-client.js index b0973ed..df1aa99 100644 --- a/site/nostr-client.js +++ b/site/nostr-client.js @@ -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: , 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 `
${results.map((item) => `
@@ -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();