diff --git a/modules/rpubs/components/folk-pubs-editor.ts b/modules/rpubs/components/folk-pubs-editor.ts index 07ebcd0..81e0b58 100644 --- a/modules/rpubs/components/folk-pubs-editor.ts +++ b/modules/rpubs/components/folk-pubs-editor.ts @@ -85,6 +85,7 @@ export class FolkPubsEditor extends HTMLElement { { target: '.format-btn', title: "Format", message: "Choose a pocket-book format — digest, half-letter, A6, and more.", advanceOnClick: false }, { target: '.btn-generate', title: "Generate PDF", message: "Generate a print-ready PDF in the selected format.", advanceOnClick: false }, { target: '.btn-new-draft', title: "Drafts", message: "Save multiple drafts with real-time collaborative sync.", advanceOnClick: false }, + { target: '.btn-zine-gen', title: "Zine Generator", message: "Generate an AI-illustrated 8-page zine — pick a topic, style, and tone, then edit any section before printing.", advanceOnClick: false }, ]; set formats(val: BookFormat[]) { @@ -431,6 +432,10 @@ export class FolkPubsEditor extends HTMLElement { ${this._loading ? "Generating..." : "Generate PDF"} + + 📰 AI Zine Generator + + ${this._error ? `
${this.escapeHtml(this._error)}
` : ""} ${this._pdfUrl ? ` @@ -845,6 +850,23 @@ export class FolkPubsEditor extends HTMLElement { .btn-generate:hover { background: var(--rs-primary-hover); } .btn-generate:disabled { opacity: 0.5; cursor: not-allowed; } + .btn-zine-gen { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.5rem 1rem; + border-radius: 0.5rem; + background: linear-gradient(135deg, #f59e0b, #ef4444); + color: #fff; + font-size: 0.8rem; + font-weight: 600; + text-decoration: none; + text-align: center; + justify-content: center; + transition: opacity 0.15s; + } + .btn-zine-gen:hover { opacity: 0.85; } + .error { color: #f87171; font-size: 0.8rem; diff --git a/server/mi-routes.ts b/server/mi-routes.ts index 3920ba5..24d0a50 100644 --- a/server/mi-routes.ts +++ b/server/mi-routes.ts @@ -225,7 +225,9 @@ Given raw unstructured content (pasted text, meeting notes, link dumps, etc.), analyze it and classify each distinct piece into the most appropriate canvas shape type. ## Shape Mapping Rules -- URLs / links → folk-embed (set url prop) +- Image URLs (.png, .jpg, .gif, .webp, .svg) → folk-image (set src prop) +- Simple links / URLs (not embeddable video/interactive) → folk-bookmark (set url prop) +- Embeddable URLs (YouTube, Twitter, Google Maps, Gather, etc.) → folk-embed (set url prop) - Dates / events / schedules → folk-calendar (set title, description props) - Locations / addresses / places → folk-map (set query prop) - Action items / TODOs / tasks → folk-workflow-block (set label, blockType:"action" props) @@ -249,7 +251,8 @@ Return a JSON object with: - If the content is too short or trivial for multiple shapes, still return at least one shape`; const KNOWN_TRIAGE_SHAPES = new Set([ - "folk-markdown", "folk-embed", "folk-calendar", "folk-map", + "folk-markdown", "folk-embed", "folk-image", "folk-bookmark", + "folk-calendar", "folk-map", "folk-workflow-block", "folk-social-post", "folk-choice-vote", "folk-prompt", "folk-image-gen", "folk-slide", ]); diff --git a/website/canvas.html b/website/canvas.html index 2701b6b..00471ac 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -3860,14 +3860,45 @@ const overlay = document.getElementById("triage-drop-overlay"); let dragEnterCount = 0; + const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|svg|bmp|ico)(\?.*)?$/i; + const URL_RE = /^https?:\/\/\S+$/i; + function startTriage(text, type) { const mgr = new TriageManager(); const panel = new MiTriagePanel(mgr); mgr.analyze(text, type); } + async function handleImageFile(file) { + const reader = new FileReader(); + reader.onload = async () => { + try { + const res = await fetch("/api/image-upload", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ image: reader.result }), + }); + const data = await res.json(); + if (data.url) { + window.__canvasApi.newShape("folk-image", { src: data.url, alt: file.name }); + } + } catch (err) { + console.error("[canvas] image upload failed:", err); + } + }; + reader.readAsDataURL(file); + } + + function handleUrl(url) { + if (IMAGE_EXT_RE.test(url)) { + window.__canvasApi.newShape("folk-image", { src: url }); + } else { + window.__canvasApi.newShape("folk-bookmark", { url }); + } + } + document.addEventListener("dragenter", (e) => { - if (e.dataTransfer?.types?.includes("text/plain") || e.dataTransfer?.types?.includes("text/uri-list")) { + if (e.dataTransfer?.types?.includes("text/plain") || e.dataTransfer?.types?.includes("text/uri-list") || e.dataTransfer?.types?.includes("Files")) { dragEnterCount++; overlay.classList.add("active"); } @@ -3888,10 +3919,24 @@ document.addEventListener("drop", (e) => { dragEnterCount = 0; overlay.classList.remove("active"); - const text = e.dataTransfer?.getData("text/plain") || e.dataTransfer?.getData("text/uri-list") || ""; - if (text.trim()) { + + // 1. Check for image files + const imageFile = Array.from(e.dataTransfer?.files || []).find(f => f.type.startsWith("image/")); + if (imageFile) { e.preventDefault(); - startTriage(text, "drop"); + handleImageFile(imageFile); + return; + } + + // 2. Check for text/URL + const text = (e.dataTransfer?.getData("text/plain") || e.dataTransfer?.getData("text/uri-list") || "").trim(); + if (text) { + e.preventDefault(); + if (URL_RE.test(text)) { + handleUrl(text); + } else { + startTriage(text, "drop"); + } } }); @@ -3899,8 +3944,32 @@ document.addEventListener("paste", (e) => { const el = document.activeElement; if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) return; - const text = e.clipboardData?.getData("text/plain") || ""; - if (text.trim() && text.length > 20) { + + // 1. Check for image in clipboard + const items = Array.from(e.clipboardData?.items || []); + const imageItem = items.find(item => item.type.startsWith("image/")); + if (imageItem) { + const file = imageItem.getAsFile(); + if (file) { + e.preventDefault(); + handleImageFile(file); + return; + } + } + + // 2. Check for text + const text = (e.clipboardData?.getData("text/plain") || "").trim(); + if (!text) return; + + // 3. URL detection + if (URL_RE.test(text)) { + e.preventDefault(); + handleUrl(text); + return; + } + + // 4. Long text → triage (existing behavior) + if (text.length > 20) { e.preventDefault(); startTriage(text, "paste"); } @@ -3930,6 +3999,8 @@ document.getElementById("new-piano").addEventListener("click", () => setPendingTool("folk-piano")); document.getElementById("new-embed").addEventListener("click", () => setPendingTool("folk-embed")); + document.getElementById("new-image").addEventListener("click", () => setPendingTool("folk-image")); + document.getElementById("new-bookmark").addEventListener("click", () => setPendingTool("folk-bookmark")); document.getElementById("new-calendar").addEventListener("click", () => setPendingTool("folk-calendar")); document.getElementById("new-map").addEventListener("click", () => setPendingTool("folk-map")); document.getElementById("new-image-gen").addEventListener("click", () => setPendingTool("folk-image-gen"));