diff --git a/lib/folk-bookmark.ts b/lib/folk-bookmark.ts new file mode 100644 index 0000000..2581f9d --- /dev/null +++ b/lib/folk-bookmark.ts @@ -0,0 +1,348 @@ +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; + +const styles = css` + :host { + background: var(--rs-bg-surface, #fff); + color: var(--rs-text-primary, #1e293b); + border-radius: 10px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); + min-width: 240px; + min-height: 100px; + cursor: pointer; + overflow: hidden; + } + + .card { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; + border-radius: 10px; + } + + .card-image { + width: 100%; + height: 160px; + object-fit: cover; + display: block; + flex-shrink: 0; + } + + .card-body { + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; + min-height: 0; + } + + .card-title { + font-size: 14px; + font-weight: 600; + line-height: 1.3; + color: var(--rs-text-primary, #1e293b); + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + + .card-description { + font-size: 12px; + color: #64748b; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + + .card-domain { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: #94a3b8; + margin-top: auto; + padding-top: 4px; + } + + .card-favicon { + width: 14px; + height: 14px; + border-radius: 2px; + } + + .url-input-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + padding: 24px; + gap: 12px; + } + + .url-input-icon { + font-size: 28px; + } + + .url-input-label { + font-size: 13px; + color: #94a3b8; + } + + .url-input { + width: 100%; + max-width: 280px; + padding: 10px 14px; + border: 2px solid var(--rs-input-border, #e2e8f0); + border-radius: 8px; + font-size: 13px; + outline: none; + background: var(--rs-input-bg, #fff); + color: var(--rs-input-text, inherit); + } + + .url-input:focus { + border-color: #6366f1; + } + + .loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: #94a3b8; + font-size: 13px; + } +`; + +declare global { + interface HTMLElementTagNameMap { + "folk-bookmark": FolkBookmark; + } +} + +export class FolkBookmark extends FolkShape { + static override tagName = "folk-bookmark"; + + static { + const sheet = new CSSStyleSheet(); + const parentRules = Array.from(FolkShape.styles.cssRules) + .map((r) => r.cssText) + .join("\n"); + const childRules = Array.from(styles.cssRules) + .map((r) => r.cssText) + .join("\n"); + sheet.replaceSync(`${parentRules}\n${childRules}`); + this.styles = sheet; + } + + #url: string | null = null; + #title: string = ""; + #description: string = ""; + #image: string | null = null; + #domain: string = ""; + + get url() { return this.#url; } + set url(value: string | null) { + this.#url = value; + this.requestUpdate("url"); + this.dispatchEvent(new CustomEvent("url-change", { detail: { url: value } })); + } + + get bookmarkTitle() { return this.#title; } + set bookmarkTitle(v: string) { this.#title = v; } + + get description() { return this.#description; } + set description(v: string) { this.#description = v; } + + get image() { return this.#image; } + set image(v: string | null) { this.#image = v; } + + get domain() { return this.#domain; } + set domain(v: string) { this.#domain = v; } + + override createRenderRoot() { + const root = super.createRenderRoot(); + + this.#url = this.getAttribute("url") || null; + + const wrapper = document.createElement("div"); + wrapper.innerHTML = html` +
+ ๐Ÿ”– + Enter a URL to create a bookmark card + +
+ `; + + const slot = root.querySelector("slot"); + const containerDiv = slot?.parentElement as HTMLElement; + if (containerDiv) { + containerDiv.replaceWith(wrapper); + } + + const urlInputContainer = wrapper.querySelector(".url-input-container") as HTMLElement; + const urlInput = wrapper.querySelector(".url-input") as HTMLInputElement; + + const handleUrlSubmit = () => { + let inputUrl = urlInput.value.trim(); + if (!inputUrl) return; + if (!inputUrl.startsWith("http://") && !inputUrl.startsWith("https://")) { + inputUrl = `https://${inputUrl}`; + } + this.url = inputUrl; + this.#fetchAndRender(wrapper, urlInputContainer); + }; + + urlInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleUrlSubmit(); + } + }); + + urlInput.addEventListener("blur", () => { + if (urlInput.value.trim()) handleUrlSubmit(); + }); + + // If URL is already set with cached data, render card directly + if (this.#url && this.#title) { + this.#renderCard(wrapper, urlInputContainer); + } else if (this.#url) { + this.#fetchAndRender(wrapper, urlInputContainer); + } + + // Click to open URL in new tab + wrapper.addEventListener("click", (e) => { + if ((e.target as HTMLElement).tagName === "INPUT") return; + if (this.#url) { + window.open(this.#url, "_blank", "noopener,noreferrer"); + } + }); + + return root; + } + + async #fetchAndRender(wrapper: HTMLElement, urlInputContainer: HTMLElement) { + if (!this.#url) return; + + // Show loading state + urlInputContainer.innerHTML = `
Loading previewโ€ฆ
`; + + try { + const res = await fetch(`/api/link-preview?url=${encodeURIComponent(this.#url)}`); + if (res.ok) { + const data = await res.json(); + this.#title = data.title || ""; + this.#description = data.description || ""; + this.#image = data.image || null; + this.#domain = data.domain || ""; + } + } catch { + // Fallback: derive domain from URL + } + + // Ensure we have at least domain + if (!this.#domain && this.#url) { + try { + this.#domain = new URL(this.#url).hostname.replace("www.", ""); + } catch {} + } + if (!this.#title) { + this.#title = this.#domain || this.#url || "Link"; + } + + this.#renderCard(wrapper, urlInputContainer); + } + + #renderCard(wrapper: HTMLElement, urlInputContainer: HTMLElement) { + urlInputContainer.remove(); + + const card = document.createElement("div"); + card.className = "card"; + + let cardHtml = ""; + + if (this.#image) { + cardHtml += ``; + } + + const faviconUrl = this.#url + ? `https://www.google.com/s2/favicons?domain=${new URL(this.#url).hostname}&sz=32` + : ""; + + cardHtml += ` +
+
${this.#escapeHtml(this.#title)}
+ ${this.#description ? `
${this.#escapeHtml(this.#description)}
` : ""} +
+ ${faviconUrl ? `` : ""} + ${this.#escapeHtml(this.#domain)} +
+
+ `; + + card.innerHTML = cardHtml; + + // Handle image load error โ€” hide image area + const img = card.querySelector(".card-image") as HTMLImageElement; + if (img) { + img.onerror = () => img.remove(); + } + + wrapper.appendChild(card); + } + + #escapeHtml(str: string): string { + return str.replace(/&/g, "&").replace(//g, ">"); + } + + #escapeAttr(str: string): string { + return str.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); + } + + static override fromData(data: Record): FolkBookmark { + const shape = FolkShape.fromData(data) as FolkBookmark; + if (data.url) shape.url = data.url; + if (data.title) shape.bookmarkTitle = data.title; + if (data.description) shape.description = data.description; + if (data.image) shape.image = data.image; + if (data.domain) shape.domain = data.domain; + return shape; + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-bookmark", + url: this.url, + title: this.#title, + description: this.#description, + image: this.#image, + domain: this.#domain, + }; + } + + override applyData(data: Record): void { + super.applyData(data); + if ("url" in data && this.url !== data.url) { + this.url = data.url; + } + if ("title" in data) this.#title = data.title; + if ("description" in data) this.#description = data.description; + if ("image" in data) this.#image = data.image; + if ("domain" in data) this.#domain = data.domain; + } +} diff --git a/lib/folk-image.ts b/lib/folk-image.ts new file mode 100644 index 0000000..8d2e161 --- /dev/null +++ b/lib/folk-image.ts @@ -0,0 +1,333 @@ +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; + +const styles = css` + :host { + background: var(--rs-bg-surface, #fff); + color: var(--rs-text-primary, #1e293b); + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + min-width: 200px; + min-height: 150px; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: #22c55e; + color: white; + border-radius: 8px 8px 0 0; + font-size: 12px; + font-weight: 600; + cursor: move; + } + + .header-title { + display: flex; + align-items: center; + gap: 6px; + } + + .header-actions { + display: flex; + gap: 4px; + } + + .header-actions button { + background: transparent; + border: none; + color: white; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + font-size: 14px; + } + + .header-actions button:hover { + background: rgba(255, 255, 255, 0.2); + } + + .content { + width: 100%; + height: calc(100% - 36px); + position: relative; + border-radius: 0 0 8px 8px; + overflow: hidden; + } + + .drop-zone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + padding: 24px; + gap: 12px; + border: 2px dashed var(--rs-input-border, #e2e8f0); + border-radius: 0 0 8px 8px; + margin: 8px; + text-align: center; + color: #94a3b8; + font-size: 13px; + transition: border-color 0.2s, background 0.2s; + } + + .drop-zone.dragover { + border-color: #22c55e; + background: rgba(34, 197, 94, 0.05); + } + + .drop-zone-icon { + font-size: 32px; + } + + .url-input { + width: 100%; + max-width: 300px; + padding: 10px 14px; + border: 2px solid var(--rs-input-border, #e2e8f0); + border-radius: 8px; + font-size: 13px; + outline: none; + background: var(--rs-input-bg, #fff); + color: var(--rs-input-text, inherit); + } + + .url-input:focus { + border-color: #22c55e; + } + + .image-display { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + } +`; + +declare global { + interface HTMLElementTagNameMap { + "folk-image": FolkImage; + } +} + +export class FolkImage extends FolkShape { + static override tagName = "folk-image"; + + static { + const sheet = new CSSStyleSheet(); + const parentRules = Array.from(FolkShape.styles.cssRules) + .map((r) => r.cssText) + .join("\n"); + const childRules = Array.from(styles.cssRules) + .map((r) => r.cssText) + .join("\n"); + sheet.replaceSync(`${parentRules}\n${childRules}`); + this.styles = sheet; + } + + #src: string | null = null; + #alt: string = ""; + + get src() { + return this.#src; + } + + set src(value: string | null) { + this.#src = value; + this.requestUpdate("src"); + this.dispatchEvent(new CustomEvent("src-change", { detail: { src: value } })); + } + + get alt() { + return this.#alt; + } + + set alt(value: string) { + this.#alt = value; + } + + override createRenderRoot() { + const root = super.createRenderRoot(); + + this.#src = this.getAttribute("src") || null; + this.#alt = this.getAttribute("alt") || ""; + + const wrapper = document.createElement("div"); + wrapper.innerHTML = html` +
+ + ๐Ÿ–ผ๏ธ + Image + +
+ +
+
+
+
+ ๐Ÿ–ผ๏ธ + Paste, drop, or enter image URLโ€ฆ + +
+
+ `; + + const slot = root.querySelector("slot"); + const containerDiv = slot?.parentElement as HTMLElement; + if (containerDiv) { + containerDiv.replaceWith(wrapper); + } + + const content = wrapper.querySelector(".content") as HTMLElement; + const dropZone = wrapper.querySelector(".drop-zone") as HTMLElement; + const urlInput = wrapper.querySelector(".url-input") as HTMLInputElement; + const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement; + + // URL input + const handleUrlSubmit = () => { + let inputUrl = urlInput.value.trim(); + if (!inputUrl) return; + if (!inputUrl.startsWith("http://") && !inputUrl.startsWith("https://")) { + inputUrl = `https://${inputUrl}`; + } + this.src = inputUrl; + this.#renderImage(content, dropZone); + }; + + urlInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleUrlSubmit(); + } + }); + + urlInput.addEventListener("blur", () => { + if (urlInput.value.trim()) handleUrlSubmit(); + }); + + // Drop image files directly onto shape + dropZone.addEventListener("dragover", (e) => { + e.preventDefault(); + e.stopPropagation(); + dropZone.classList.add("dragover"); + }); + + dropZone.addEventListener("dragleave", () => { + dropZone.classList.remove("dragover"); + }); + + dropZone.addEventListener("drop", (e) => { + e.preventDefault(); + e.stopPropagation(); + dropZone.classList.remove("dragover"); + + const file = Array.from(e.dataTransfer?.files || []).find((f) => + f.type.startsWith("image/") + ); + if (file) { + this.#uploadFile(file, content, dropZone); + return; + } + + const url = e.dataTransfer?.getData("text/plain") || ""; + if (url.trim()) { + this.src = url.trim(); + this.#renderImage(content, dropZone); + } + }); + + // Close button + closeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + // If src already set, render image + if (this.#src) { + this.#renderImage(content, dropZone); + } + + return root; + } + + async #uploadFile( + file: File, + content: HTMLElement, + dropZone: HTMLElement + ) { + 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) { + this.src = data.url; + this.alt = file.name; + this.#renderImage(content, dropZone); + } + } catch (err) { + console.error("[folk-image] upload failed:", err); + } + }; + reader.readAsDataURL(file); + } + + #renderImage(content: HTMLElement, dropZone: HTMLElement) { + if (!this.#src) return; + dropZone.style.display = "none"; + + // Remove existing image if any + const existing = content.querySelector(".image-display"); + if (existing) existing.remove(); + + const img = document.createElement("img"); + img.className = "image-display"; + img.src = this.#src; + img.alt = this.#alt || "Image"; + img.draggable = false; + content.appendChild(img); + + // Update header title + const titleText = this.renderRoot.querySelector(".title-text"); + if (titleText) { + titleText.textContent = this.#alt || "Image"; + } + } + + static override fromData(data: Record): FolkImage { + const shape = FolkShape.fromData(data) as FolkImage; + if (data.src) shape.src = data.src; + if (data.alt) shape.alt = data.alt; + return shape; + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-image", + src: this.src, + alt: this.alt, + }; + } + + override applyData(data: Record): void { + super.applyData(data); + if ("src" in data && this.src !== data.src) { + this.src = data.src; + } + if ("alt" in data && this.alt !== data.alt) { + this.alt = data.alt; + } + } +} diff --git a/lib/index.ts b/lib/index.ts index 7cd6533..53bb02e 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -30,6 +30,8 @@ export * from "./folk-chat"; export * from "./folk-google-item"; export * from "./folk-piano"; export * from "./folk-embed"; +export * from "./folk-image"; +export * from "./folk-bookmark"; export * from "./folk-calendar"; export * from "./folk-map"; diff --git a/modules/rpubs/landing.ts b/modules/rpubs/landing.ts index 5ffc58a..ce91b94 100644 --- a/modules/rpubs/landing.ts +++ b/modules/rpubs/landing.ts @@ -95,8 +95,82 @@ export function renderLanding(): string { - +
+
+
+
+
+ 📰 + Zine Generator +
+

AI-powered zines in minutes

+

+ Pick a topic, choose a visual style and tone, and let AI generate a + complete 8-page zine — cover, content, and call-to-action — + with illustrations and editable text. Tweak any section, regenerate + images with feedback, then download or send to print. +

+
    +
  • 6 visual styles — punk zine, mycelial, minimal, collage, retro, academic
  • +
  • 5 tones — rebellious, regenerative, playful, informative, poetic
  • +
  • Per-section editing — regenerate any text or image with feedback
  • +
  • Print-ready — download and fold, or send straight to rPubs for binding
  • +
+ +
+
+

Choose your style

+
+
+

📷

+

Punk

+

Xerox & DIY

+
+
+

🍄

+

Mycelial

+

Organic textures

+
+
+

+

Minimal

+

Clean lines

+
+
+

🎨

+

Collage

+

Mixed media

+
+
+

🎶

+

Retro

+

70s vibes

+
+
+

📚

+

Academic

+

Infographic

+
+
+

+ Each style generates unique illustrations, typography, and layouts +

+
+
+
+
+ + +

Bulk orders, better prices

diff --git a/modules/rpubs/mod.ts b/modules/rpubs/mod.ts index 1a49236..297ff0b 100644 --- a/modules/rpubs/mod.ts +++ b/modules/rpubs/mod.ts @@ -319,6 +319,12 @@ routes.get("/api/artifact/:id/pdf", async (c) => { }); }); +// โ”€โ”€ Page: Zine Generator (redirect to canvas with auto-spawn) โ”€โ”€ +routes.get("/zine", (c) => { + const spaceSlug = c.req.param("space") || "personal"; + return c.redirect(`/${spaceSlug}?tool=folk-zine-gen`); +}); + // โ”€โ”€ Page: Editor โ”€โ”€ routes.get("/", (c) => { const spaceSlug = c.req.param("space") || "personal"; diff --git a/website/canvas.html b/website/canvas.html index fe17479..fbd61c4 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -1707,6 +1707,8 @@ folk-google-item, folk-piano, folk-embed, + folk-image, + folk-bookmark, folk-calendar, folk-map, folk-image-gen, @@ -2485,6 +2487,8 @@ FolkGoogleItem, FolkPiano, FolkEmbed, + FolkImage, + FolkBookmark, FolkCalendar, FolkMap, FolkImageGen, @@ -2613,6 +2617,8 @@ FolkGoogleItem.define(); FolkPiano.define(); FolkEmbed.define(); + FolkImage.define(); + FolkBookmark.define(); FolkCalendar.define(); FolkMap.define(); FolkImageGen.define(); @@ -2654,6 +2660,8 @@ shapeRegistry.register("folk-google-item", FolkGoogleItem); shapeRegistry.register("folk-piano", FolkPiano); shapeRegistry.register("folk-embed", FolkEmbed); + shapeRegistry.register("folk-image", FolkImage); + shapeRegistry.register("folk-bookmark", FolkBookmark); shapeRegistry.register("folk-calendar", FolkCalendar); shapeRegistry.register("folk-map", FolkMap); shapeRegistry.register("folk-image-gen", FolkImageGen); @@ -4222,6 +4230,19 @@ } } + // Auto-spawn shape from ?tool= URL param (e.g. ?tool=folk-zine-gen) + const toolParam = urlParams.get("tool"); + if (toolParam && shapeRegistry.has(toolParam)) { + // Wait for sync to initialize, then auto-place the shape + setTimeout(() => { + newShape(toolParam); + // Clean up URL param without reload + const cleanUrl = new URL(window.location.href); + cleanUrl.searchParams.delete("tool"); + history.replaceState(null, "", cleanUrl.pathname + cleanUrl.search); + }, 800); + } + // Feed shape โ€” pull live data from another layer/module // Arrow connection mode let connectMode = false;