mirror of
https://github.com/PR0M3TH3AN/VoxVera.git
synced 2026-11-03 13:10:40 +00:00
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:
1 parent
a4cb8dffcb
commit
b344f92be7
5 files changed
+345
-13
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user