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"));