import { BaseBoxShapeUtil, Geometry2d, HTMLContainer, Rectangle2d, TLBaseShape, TLShapeId, createShapeId, } from "tldraw" import React, { useState, useRef, useEffect } from "react" import { StandardizedToolWrapper } from "@/components/StandardizedToolWrapper" import { usePinnedToView } from "@/hooks/usePinnedToView" import { useMaximize } from "@/hooks/useMaximize" // Note: Image generation now uses zine.jeffemmett.com API which proxies through RunPod // ============================================================================ // Types // ============================================================================ type ZinePhase = 'ideation' | 'drafts' | 'feedback' | 'finalizing' | 'complete' type ZineStyle = 'punk-zine' | 'minimal' | 'collage' | 'retro' | 'academic' type ZineTone = 'rebellious' | 'playful' | 'informative' | 'poetic' interface ChatMessage { id: string role: 'user' | 'assistant' content: string timestamp: number } interface ZinePageOutline { pageNumber: number // 1-8 type: 'cover' | 'content' | 'cta' title: string subtitle?: string keyPoints: string[] hashtags: string[] imagePrompt: string } interface GeneratedZinePage { pageNumber: number imageUrl: string shapeId?: string // Canvas shape ID if spawned outline: ZinePageOutline generationPrompt: string timestamp: number version: number // 1=draft, 2+=refined } interface PageFeedback { pageNumber: number feedbackText: string requestedChanges: string[] approved: boolean } interface ZineTemplate { id: string name: string topic: string style: ZineStyle tone: ZineTone pages: GeneratedZinePage[] createdAt: number } type IMycroZineGenerator = TLBaseShape< "MycroZineGenerator", { w: number h: number // Zine Identity zineId: string title: string topic: string style: ZineStyle tone: ZineTone // Phase State phase: ZinePhase // Ideation Phase ideationMessages: ChatMessage[] contentOutline: ZinePageOutline[] | null // Drafts Phase draftPages: GeneratedZinePage[] spawnedShapeIds: string[] currentGeneratingPage: number // 0 = not generating, 1-8 = generating that page // Feedback Phase pageFeedback: PageFeedback[] // Final Phase finalPages: GeneratedZinePage[] printLayoutUrl: string | null // UI State isLoading: boolean error: string | null tags: string[] pinnedToView: boolean } > // ============================================================================ // Constants // ============================================================================ const STYLES: Record = { 'punk-zine': 'Xerox texture, high contrast B&W, DIY collage, hand-drawn typography', 'minimal': 'Clean lines, white space, modern sans-serif, subtle gradients', 'collage': 'Layered imagery, mixed media textures, vintage photographs', 'retro': '1970s aesthetic, earth tones, groovy typography, halftone patterns', 'academic': 'Diagram-heavy, annotated illustrations, infographic elements', } const TONES: Record = { 'rebellious': 'Defiant, anti-establishment, punk energy', 'playful': 'Fun, whimsical, approachable', 'informative': 'Educational, clear, accessible', 'poetic': 'Lyrical, metaphorical, evocative', } const PAGE_TEMPLATES = [ { type: 'cover', description: 'Bold title, subtitle, visual hook' }, { type: 'content', description: 'Key concepts with visual explanations' }, { type: 'content', description: 'Deep dive on main topic' }, { type: 'content', description: 'Supporting information' }, { type: 'content', description: 'Practical applications' }, { type: 'content', description: 'Community or movement aspect' }, { type: 'content', description: 'Philosophy or manifesto' }, { type: 'cta', description: 'Call-to-action with QR codes' }, ] // ============================================================================ // Shape Util // ============================================================================ export class MycroZineGeneratorShape extends BaseBoxShapeUtil { static override type = "MycroZineGenerator" as const // Mycro-zine theme color: Punk green static readonly PRIMARY_COLOR = "#00ff00" static readonly SECONDARY_COLOR = "#1a1a1a" MIN_WIDTH = 400 as const MIN_HEIGHT = 500 as const DEFAULT_WIDTH = 450 as const DEFAULT_HEIGHT = 600 as const getDefaultProps(): IMycroZineGenerator["props"] { return { w: this.DEFAULT_WIDTH, h: this.DEFAULT_HEIGHT, zineId: `zine_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, title: '', topic: '', style: 'punk-zine', tone: 'rebellious', phase: 'ideation', ideationMessages: [], contentOutline: null, draftPages: [], spawnedShapeIds: [], currentGeneratingPage: 0, pageFeedback: [], finalPages: [], printLayoutUrl: null, isLoading: false, error: null, tags: ['zine', 'mycrozine', 'print'], pinnedToView: false, } } getGeometry(shape: IMycroZineGenerator): Geometry2d { return new Rectangle2d({ width: Math.max(shape.props.w, 1), height: Math.max(shape.props.h, 1), isFilled: true, }) } component(shape: IMycroZineGenerator) { const editor = this.editor const isSelected = editor.getSelectedShapeIds().includes(shape.id) usePinnedToView(editor, shape.id, shape.props.pinnedToView) const { isMaximized, toggleMaximize } = useMaximize({ editor: editor, shapeId: shape.id, currentW: shape.props.w, currentH: shape.props.h, shapeType: 'MycroZineGenerator', }) const [isMinimized, setIsMinimized] = useState(false) const [inputValue, setInputValue] = useState('') const messagesEndRef = useRef(null) // Scroll to bottom of messages useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [shape.props.ideationMessages]) const handlePinToggle = () => { editor.updateShape({ id: shape.id, type: "MycroZineGenerator", props: { pinnedToView: !shape.props.pinnedToView }, }) } const handleClose = () => { editor.deleteShape(shape.id) } const handleMinimize = () => { setIsMinimized(!isMinimized) } const handleTagsChange = (newTags: string[]) => { editor.updateShape({ id: shape.id, type: "MycroZineGenerator", props: { tags: newTags }, }) } // Update shape props helper const updateProps = (updates: Partial) => { editor.updateShape({ id: shape.id, type: "MycroZineGenerator", props: updates, }) } // Add a chat message const addMessage = (role: 'user' | 'assistant', content: string) => { const newMessage: ChatMessage = { id: `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, role, content, timestamp: Date.now(), } updateProps({ ideationMessages: [...shape.props.ideationMessages, newMessage], }) return newMessage } // Generate content outline from ideation const generateOutline = async () => { if (!shape.props.topic) { updateProps({ error: 'Please enter a topic first' }) return } updateProps({ isLoading: true, error: null }) try { // Build outline based on topic and conversation const outline: ZinePageOutline[] = PAGE_TEMPLATES.map((template, index) => ({ pageNumber: index + 1, type: template.type as 'cover' | 'content' | 'cta', title: index === 0 ? shape.props.topic.toUpperCase() : `Page ${index + 1}`, subtitle: template.description, keyPoints: [], hashtags: [], imagePrompt: `${STYLES[shape.props.style]}. ${TONES[shape.props.tone]}. ${template.description} for ${shape.props.topic}.`, })) // Add assistant message with outline summary addMessage('assistant', `Great! I've created an outline for your "${shape.props.topic}" zine:\n\n${outline.map(p => `Page ${p.pageNumber}: ${p.title}`).join('\n')}\n\nClick "Generate Drafts" when you're ready to create the pages!`) updateProps({ contentOutline: outline, title: shape.props.topic.toUpperCase(), isLoading: false, }) } catch (err) { updateProps({ isLoading: false, error: `Failed to generate outline: ${err instanceof Error ? err.message : String(err)}`, }) } } // Handle user message in ideation phase const handleSendMessage = async () => { const message = inputValue.trim() if (!message) return setInputValue('') addMessage('user', message) // If this is the first message and no topic set, use it as topic if (!shape.props.topic && shape.props.ideationMessages.length === 0) { updateProps({ topic: message }) // Auto-respond setTimeout(() => { addMessage('assistant', `Let's create an 8-page zine about "${message}"!\n\nI'll use the ${shape.props.style} style with a ${shape.props.tone} tone.\n\nFeel free to:\n• Add more details about what to cover\n• Specify any key points or themes\n• Change the style/tone using the dropdowns\n\nOr click "Generate Outline" to proceed!`) }, 500) } else { // Continue conversation setTimeout(() => { addMessage('assistant', `Got it! I'll incorporate that into the zine. ${shape.props.contentOutline ? 'Ready to generate drafts!' : 'Click "Generate Outline" when ready.'}`) }, 500) } } // Generate draft pages using Gemini const generateDrafts = async () => { if (!shape.props.contentOutline) { await generateOutline() if (!shape.props.contentOutline) return } updateProps({ phase: 'drafts', isLoading: true, error: null, currentGeneratingPage: 1, }) const outline = shape.props.contentOutline! const generatedPages: GeneratedZinePage[] = [] for (let i = 0; i < outline.length; i++) { const pageOutline = outline[i] updateProps({ currentGeneratingPage: i + 1 }) try { // Build the image generation prompt const prompt = buildImagePrompt(pageOutline, shape.props.topic, shape.props.style, shape.props.tone) // Call Gemini MCP for image generation // Note: In actual implementation, this would call the MCP server // For now, we'll use a placeholder approach const imageUrl = await generatePageImage(prompt, i + 1) generatedPages.push({ pageNumber: i + 1, imageUrl, outline: pageOutline, generationPrompt: prompt, timestamp: Date.now(), version: 1, }) // Spawn image on canvas const spawnedId = await spawnPageOnCanvas(editor, imageUrl, i, shape) if (spawnedId) { generatedPages[generatedPages.length - 1].shapeId = spawnedId } updateProps({ draftPages: [...generatedPages] }) } catch (err) { console.error(`Failed to generate page ${i + 1}:`, err) updateProps({ error: `Failed to generate page ${i + 1}: ${err instanceof Error ? err.message : String(err)}`, }) } } // Initialize feedback for all pages const initialFeedback: PageFeedback[] = outline.map((_, i) => ({ pageNumber: i + 1, feedbackText: '', requestedChanges: [], approved: false, })) updateProps({ draftPages: generatedPages, pageFeedback: initialFeedback, spawnedShapeIds: generatedPages.map(p => p.shapeId).filter(Boolean) as string[], isLoading: false, currentGeneratingPage: 0, phase: generatedPages.length === 8 ? 'feedback' : 'drafts', }) } // Approve a page const approvePage = (pageNumber: number) => { const newFeedback = shape.props.pageFeedback.map(f => f.pageNumber === pageNumber ? { ...f, approved: true } : f ) updateProps({ pageFeedback: newFeedback }) } // Add feedback to a page const addPageFeedback = (pageNumber: number, feedback: string) => { const newFeedback = shape.props.pageFeedback.map(f => f.pageNumber === pageNumber ? { ...f, feedbackText: feedback, requestedChanges: [feedback] } : f ) updateProps({ pageFeedback: newFeedback }) } // Check if all pages are approved or have feedback const allPagesReviewed = shape.props.pageFeedback.every( f => f.approved || f.feedbackText.length > 0 ) // Move to finalization const startFinalization = async () => { const pagesNeedingUpdate = shape.props.pageFeedback.filter(f => !f.approved && f.feedbackText) if (pagesNeedingUpdate.length === 0) { // All approved, skip to complete updateProps({ phase: 'complete', finalPages: shape.props.draftPages, }) return } updateProps({ phase: 'finalizing', isLoading: true, error: null, }) const finalPages = [...shape.props.draftPages] for (const feedback of pagesNeedingUpdate) { updateProps({ currentGeneratingPage: feedback.pageNumber }) try { const originalPage = shape.props.draftPages.find(p => p.pageNumber === feedback.pageNumber) if (!originalPage) continue // Regenerate with feedback incorporated const prompt = `${originalPage.generationPrompt}\n\nIMPORTANT REVISIONS: ${feedback.feedbackText}` const imageUrl = await generatePageImage(prompt, feedback.pageNumber) finalPages[feedback.pageNumber - 1] = { ...originalPage, imageUrl, generationPrompt: prompt, timestamp: Date.now(), version: originalPage.version + 1, } } catch (err) { console.error(`Failed to regenerate page ${feedback.pageNumber}:`, err) } } updateProps({ finalPages, isLoading: false, currentGeneratingPage: 0, phase: 'complete', }) } // Generate print layout const generatePrintLayout = async () => { updateProps({ isLoading: true, error: null }) try { // In actual implementation, this would call the mycro-zine layout generator // For now, show a success message const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5) const printUrl = `/output/${shape.props.zineId}_print_${timestamp}.png` updateProps({ printLayoutUrl: printUrl, isLoading: false, }) addMessage('assistant', `Print layout generated! Your zine is ready to download and print.`) } catch (err) { updateProps({ isLoading: false, error: `Failed to generate print layout: ${err instanceof Error ? err.message : String(err)}`, }) } } // Save as template const saveAsTemplate = () => { const template: ZineTemplate = { id: shape.props.zineId, name: shape.props.title || shape.props.topic, topic: shape.props.topic, style: shape.props.style, tone: shape.props.tone, pages: shape.props.finalPages.length > 0 ? shape.props.finalPages : shape.props.draftPages, createdAt: Date.now(), } // Save to localStorage const templates = JSON.parse(localStorage.getItem('mycrozine_templates') || '[]') templates.push(template) localStorage.setItem('mycrozine_templates', JSON.stringify(templates)) addMessage('assistant', `Template "${template.name}" saved! You can reprint it anytime from the template library.`) } // Render phase-specific content const renderPhaseContent = () => { switch (shape.props.phase) { case 'ideation': return renderIdeationPhase() case 'drafts': return renderDraftsPhase() case 'feedback': return renderFeedbackPhase() case 'finalizing': return renderFinalizingPhase() case 'complete': return renderCompletePhase() default: return null } } // ========== IDEATION PHASE ========== const renderIdeationPhase = () => (
{/* Style/Tone selectors */}
{/* Chat messages */}
{shape.props.ideationMessages.length === 0 && (
What topic would you like to make a zine about?
)} {shape.props.ideationMessages.map((msg) => (
{msg.content}
))}
{/* Input area */}
setInputValue(e.target.value)} onKeyDown={(e) => { e.stopPropagation() if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() handleSendMessage() } }} onPointerDown={(e) => e.stopPropagation()} placeholder={shape.props.topic ? "Add details or feedback..." : "Enter your zine topic..."} style={inputStyle} />
{/* Action buttons */}
{!shape.props.contentOutline && shape.props.topic && ( )} {shape.props.contentOutline && ( )}
) // ========== DRAFTS PHASE ========== const renderDraftsPhase = () => (
šŸ„
Generating Drafts...
{/* Progress bar */}
Page {shape.props.currentGeneratingPage}/8
{/* Page status list */}
{Array.from({ length: 8 }, (_, i) => i + 1).map(pageNum => { const isComplete = shape.props.draftPages.some(p => p.pageNumber === pageNum) const isGenerating = shape.props.currentGeneratingPage === pageNum return (
{isComplete ? 'āœ“' : pageNum}
) })}
) // ========== FEEDBACK PHASE ========== const renderFeedbackPhase = () => { const [selectedPage, setSelectedPage] = useState(null) const [feedbackInput, setFeedbackInput] = useState('') return (
Review your drafts. Approve pages or add feedback for revision.
{/* Page grid */}
{shape.props.draftPages.map((page) => { const feedback = shape.props.pageFeedback.find(f => f.pageNumber === page.pageNumber) const isApproved = feedback?.approved const hasFeedback = feedback?.feedbackText return (
setSelectedPage(page.pageNumber)} onPointerDown={(e) => e.stopPropagation()} style={{ position: 'relative', borderRadius: '4px', overflow: 'hidden', cursor: 'pointer', border: selectedPage === page.pageNumber ? `2px solid ${MycroZineGeneratorShape.PRIMARY_COLOR}` : isApproved ? '2px solid #4ade80' : hasFeedback ? '2px solid #fbbf24' : '2px solid #333', }} > {`Page
{page.pageNumber} {isApproved ? 'āœ“' : hasFeedback ? 'āœŽ' : ''}
) })}
{/* Selected page actions */} {selectedPage && (
Page {selectedPage}
setFeedbackInput(e.target.value)} onPointerDown={(e) => e.stopPropagation()} placeholder="Describe changes needed..." style={inputStyle} /> {feedbackInput && ( )}
)} {/* Continue button */}
) } // ========== FINALIZING PHASE ========== const renderFinalizingPhase = () => (
šŸ”„
Applying Feedback...
Regenerating {shape.props.pageFeedback.filter(f => !f.approved && f.feedbackText).length} pages
{shape.props.currentGeneratingPage > 0 && (
Currently updating page {shape.props.currentGeneratingPage}...
)}
) // ========== COMPLETE PHASE ========== const renderCompletePhase = () => { const pages = shape.props.finalPages.length > 0 ? shape.props.finalPages : shape.props.draftPages return (
šŸ„
Zine Complete!
"{shape.props.title || shape.props.topic}"
{/* Print preview - 2x4 grid */}
{pages.map((page) => ( {`Page ))}
{/* Action buttons */}
) } // Phase indicator const phaseLabels: Record = { ideation: '1. Ideation', drafts: '2. Drafts', feedback: '3. Feedback', finalizing: '4. Finalizing', complete: '5. Complete', } return ( šŸ„ MycroZine {phaseLabels[shape.props.phase]} } >
{/* Error display */} {shape.props.error && (
{shape.props.error}
)} {renderPhaseContent()}
) } override indicator(shape: IMycroZineGenerator) { return ( ) } } // ============================================================================ // Helper Functions // ============================================================================ function buildImagePrompt( pageOutline: ZinePageOutline, topic: string, style: ZineStyle, tone: ZineTone ): string { const styleDesc = STYLES[style] const toneDesc = TONES[tone] return `Punk zine page ${pageOutline.pageNumber}/8 for "${topic}". ${pageOutline.imagePrompt} Title text: "${pageOutline.title}" ${pageOutline.subtitle ? `Subtitle: "${pageOutline.subtitle}"` : ''} ${pageOutline.keyPoints.length > 0 ? `Key points: ${pageOutline.keyPoints.join(', ')}` : ''} ${pageOutline.hashtags.length > 0 ? `Hashtags: ${pageOutline.hashtags.join(' ')}` : ''} Style: ${styleDesc} Tone: ${toneDesc} High contrast black and white with neon green accent highlights. Xerox texture, DIY cut-and-paste collage aesthetic, rough edges, rebellious feel.` } interface GenerateImageResponse { success: boolean imageData?: string mimeType?: string error?: string } async function generatePageImage(prompt: string, pageNumber: number): Promise { console.log(`šŸ„ Generating page ${pageNumber} via RunPod proxy...`) console.log(`šŸ“ Prompt preview:`, prompt.substring(0, 100) + '...') // Use the mycro-zine API which proxies through RunPod (US-based, bypasses geo-restrictions) const ZINE_API_URL = 'https://zine.jeffemmett.com/api/generate-image' try { const response = await fetch(ZINE_API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ prompt }), }) if (!response.ok) { let errorMessage = `HTTP ${response.status}` try { const errorData = await response.json() as { error?: string } if (errorData.error) errorMessage = errorData.error } catch { // Ignore JSON parse errors } console.error(`āŒ API error for page ${pageNumber}:`, response.status, errorMessage) throw new Error(errorMessage) } const data: GenerateImageResponse = await response.json() if (data.success && data.imageData) { console.log(`āœ… Page ${pageNumber} generated via RunPod proxy`) // Convert base64 to data URL return `data:${data.mimeType || 'image/png'};base64,${data.imageData}` } throw new Error('No image data in response') } catch (error) { console.error(`āŒ Generation failed for page ${pageNumber}:`, error) // Fallback to placeholder return `https://via.placeholder.com/825x1275/1a1a1a/00ff00?text=Page+${pageNumber}+%28Generation+Failed%29` } } // Removed direct Gemini API functions - now using zine.jeffemmett.com proxy async function spawnPageOnCanvas( editor: any, imageUrl: string, index: number, parentShape: IMycroZineGenerator ): Promise { try { // Calculate position in a 4x2 grid to the right of the generator const col = index % 4 const row = Math.floor(index / 4) const spacing = 20 const pageWidth = 200 const pageHeight = 300 const x = parentShape.props.w + 50 + col * (pageWidth + spacing) const y = row * (pageHeight + spacing) // Get parent shape position const parentBounds = editor.getShapePageBounds(parentShape.id) if (!parentBounds) return undefined const shapeId = createShapeId() // Create an image shape on the canvas editor.createShape({ id: shapeId, type: 'image', x: parentBounds.x + x, y: parentBounds.y + y, props: { w: pageWidth, h: pageHeight, url: imageUrl, }, }) return shapeId } catch (err) { console.error('Failed to spawn page on canvas:', err) return undefined } } // ============================================================================ // Styles // ============================================================================ const inputStyle: React.CSSProperties = { flex: 1, padding: '10px 12px', backgroundColor: '#1a1a1a', border: '1px solid #333', borderRadius: '6px', color: '#fff', fontSize: '13px', outline: 'none', } const buttonStyle: React.CSSProperties = { padding: '10px 16px', backgroundColor: '#00ff00', border: 'none', borderRadius: '6px', color: '#000', fontSize: '13px', fontWeight: 'bold', cursor: 'pointer', transition: 'opacity 0.15s', } const selectStyle: React.CSSProperties = { flex: 1, padding: '8px 12px', backgroundColor: '#1a1a1a', border: '1px solid #333', borderRadius: '6px', color: '#fff', fontSize: '12px', cursor: 'pointer', }