1195 lines
38 KiB
TypeScript
1195 lines
38 KiB
TypeScript
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<ZineStyle, string> = {
|
|
'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<ZineTone, string> = {
|
|
'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<IMycroZineGenerator> {
|
|
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<HTMLDivElement>(null)
|
|
|
|
// Scroll to bottom of messages
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
}, [shape.props.ideationMessages])
|
|
|
|
const handlePinToggle = () => {
|
|
editor.updateShape<IMycroZineGenerator>({
|
|
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<IMycroZineGenerator>({
|
|
id: shape.id,
|
|
type: "MycroZineGenerator",
|
|
props: { tags: newTags },
|
|
})
|
|
}
|
|
|
|
// Update shape props helper
|
|
const updateProps = (updates: Partial<IMycroZineGenerator["props"]>) => {
|
|
editor.updateShape<IMycroZineGenerator>({
|
|
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 = () => (
|
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', gap: '12px' }}>
|
|
{/* Style/Tone selectors */}
|
|
<div style={{ display: 'flex', gap: '8px', flexShrink: 0 }}>
|
|
<select
|
|
value={shape.props.style}
|
|
onChange={(e) => updateProps({ style: e.target.value as ZineStyle })}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
style={selectStyle}
|
|
>
|
|
{Object.keys(STYLES).map(s => (
|
|
<option key={s} value={s}>{s}</option>
|
|
))}
|
|
</select>
|
|
<select
|
|
value={shape.props.tone}
|
|
onChange={(e) => updateProps({ tone: e.target.value as ZineTone })}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
style={selectStyle}
|
|
>
|
|
{Object.keys(TONES).map(t => (
|
|
<option key={t} value={t}>{t}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Chat messages */}
|
|
<div style={{
|
|
flex: 1,
|
|
overflow: 'auto',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '8px',
|
|
padding: '8px',
|
|
backgroundColor: '#1a1a1a',
|
|
borderRadius: '6px',
|
|
}}>
|
|
{shape.props.ideationMessages.length === 0 && (
|
|
<div style={{ color: '#666', fontSize: '13px', textAlign: 'center', padding: '20px' }}>
|
|
What topic would you like to make a zine about?
|
|
</div>
|
|
)}
|
|
{shape.props.ideationMessages.map((msg) => (
|
|
<div
|
|
key={msg.id}
|
|
style={{
|
|
padding: '8px 12px',
|
|
borderRadius: '8px',
|
|
maxWidth: '85%',
|
|
alignSelf: msg.role === 'user' ? 'flex-end' : 'flex-start',
|
|
backgroundColor: msg.role === 'user' ? MycroZineGeneratorShape.PRIMARY_COLOR : '#333',
|
|
color: msg.role === 'user' ? '#000' : '#fff',
|
|
fontSize: '13px',
|
|
lineHeight: '1.4',
|
|
whiteSpace: 'pre-wrap',
|
|
}}
|
|
>
|
|
{msg.content}
|
|
</div>
|
|
))}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
{/* Input area */}
|
|
<div style={{ display: 'flex', gap: '8px', flexShrink: 0 }}>
|
|
<input
|
|
type="text"
|
|
value={inputValue}
|
|
onChange={(e) => 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}
|
|
/>
|
|
<button
|
|
onClick={handleSendMessage}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
style={buttonStyle}
|
|
disabled={!inputValue.trim()}
|
|
>
|
|
Send
|
|
</button>
|
|
</div>
|
|
|
|
{/* Action buttons */}
|
|
<div style={{ display: 'flex', gap: '8px', flexShrink: 0 }}>
|
|
{!shape.props.contentOutline && shape.props.topic && (
|
|
<button
|
|
onClick={generateOutline}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
style={{ ...buttonStyle, flex: 1, backgroundColor: '#333' }}
|
|
disabled={shape.props.isLoading}
|
|
>
|
|
Generate Outline
|
|
</button>
|
|
)}
|
|
{shape.props.contentOutline && (
|
|
<button
|
|
onClick={generateDrafts}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
style={{ ...buttonStyle, flex: 1 }}
|
|
disabled={shape.props.isLoading}
|
|
>
|
|
{shape.props.isLoading ? 'Generating...' : 'Generate 8 Draft Pages →'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
// ========== DRAFTS PHASE ==========
|
|
const renderDraftsPhase = () => (
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: '16px' }}>
|
|
<div style={{ fontSize: '48px' }}>🍄</div>
|
|
<div style={{ fontSize: '18px', fontWeight: 'bold', color: '#fff' }}>Generating Drafts...</div>
|
|
|
|
{/* Progress bar */}
|
|
<div style={{ width: '80%', backgroundColor: '#333', borderRadius: '4px', overflow: 'hidden' }}>
|
|
<div style={{
|
|
width: `${(shape.props.currentGeneratingPage / 8) * 100}%`,
|
|
height: '8px',
|
|
backgroundColor: MycroZineGeneratorShape.PRIMARY_COLOR,
|
|
transition: 'width 0.3s ease',
|
|
}} />
|
|
</div>
|
|
|
|
<div style={{ color: '#999', fontSize: '14px' }}>
|
|
Page {shape.props.currentGeneratingPage}/8
|
|
</div>
|
|
|
|
{/* Page status list */}
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', justifyContent: 'center' }}>
|
|
{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 (
|
|
<div
|
|
key={pageNum}
|
|
style={{
|
|
width: '32px',
|
|
height: '32px',
|
|
borderRadius: '4px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
fontSize: '12px',
|
|
fontWeight: 'bold',
|
|
backgroundColor: isComplete ? MycroZineGeneratorShape.PRIMARY_COLOR : isGenerating ? '#555' : '#333',
|
|
color: isComplete ? '#000' : '#fff',
|
|
border: isGenerating ? `2px solid ${MycroZineGeneratorShape.PRIMARY_COLOR}` : 'none',
|
|
}}
|
|
>
|
|
{isComplete ? '✓' : pageNum}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
// ========== FEEDBACK PHASE ==========
|
|
const renderFeedbackPhase = () => {
|
|
const [selectedPage, setSelectedPage] = useState<number | null>(null)
|
|
const [feedbackInput, setFeedbackInput] = useState('')
|
|
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', gap: '12px' }}>
|
|
<div style={{ color: '#999', fontSize: '13px', textAlign: 'center' }}>
|
|
Review your drafts. Approve pages or add feedback for revision.
|
|
</div>
|
|
|
|
{/* Page grid */}
|
|
<div style={{
|
|
flex: 1,
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(4, 1fr)',
|
|
gap: '8px',
|
|
overflow: 'auto',
|
|
padding: '4px',
|
|
}}>
|
|
{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 (
|
|
<div
|
|
key={page.pageNumber}
|
|
onClick={() => 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',
|
|
}}
|
|
>
|
|
<img
|
|
src={page.imageUrl}
|
|
alt={`Page ${page.pageNumber}`}
|
|
style={{ width: '100%', aspectRatio: '3/4', objectFit: 'cover' }}
|
|
/>
|
|
<div style={{
|
|
position: 'absolute',
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
padding: '4px',
|
|
backgroundColor: 'rgba(0,0,0,0.7)',
|
|
fontSize: '10px',
|
|
color: '#fff',
|
|
textAlign: 'center',
|
|
}}>
|
|
{page.pageNumber} {isApproved ? '✓' : hasFeedback ? '✎' : ''}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Selected page actions */}
|
|
{selectedPage && (
|
|
<div style={{
|
|
padding: '12px',
|
|
backgroundColor: '#1a1a1a',
|
|
borderRadius: '6px',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '8px',
|
|
}}>
|
|
<div style={{ fontSize: '13px', color: '#fff' }}>Page {selectedPage}</div>
|
|
<div style={{ display: 'flex', gap: '8px' }}>
|
|
<button
|
|
onClick={() => {
|
|
approvePage(selectedPage)
|
|
setSelectedPage(null)
|
|
}}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
style={{ ...buttonStyle, flex: 1, backgroundColor: '#4ade80', color: '#000' }}
|
|
>
|
|
✓ Approve
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
// Toggle feedback input
|
|
}}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
style={{ ...buttonStyle, flex: 1, backgroundColor: '#fbbf24', color: '#000' }}
|
|
>
|
|
✎ Add Feedback
|
|
</button>
|
|
</div>
|
|
<input
|
|
type="text"
|
|
value={feedbackInput}
|
|
onChange={(e) => setFeedbackInput(e.target.value)}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
placeholder="Describe changes needed..."
|
|
style={inputStyle}
|
|
/>
|
|
{feedbackInput && (
|
|
<button
|
|
onClick={() => {
|
|
addPageFeedback(selectedPage, feedbackInput)
|
|
setFeedbackInput('')
|
|
setSelectedPage(null)
|
|
}}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
style={buttonStyle}
|
|
>
|
|
Save Feedback
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Continue button */}
|
|
<button
|
|
onClick={startFinalization}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
style={{
|
|
...buttonStyle,
|
|
opacity: allPagesReviewed ? 1 : 0.5,
|
|
}}
|
|
disabled={!allPagesReviewed}
|
|
>
|
|
{shape.props.pageFeedback.some(f => !f.approved && f.feedbackText)
|
|
? 'Apply Feedback & Finalize →'
|
|
: 'Finalize Zine →'}
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ========== FINALIZING PHASE ==========
|
|
const renderFinalizingPhase = () => (
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: '16px' }}>
|
|
<div style={{ fontSize: '48px' }}>🔄</div>
|
|
<div style={{ fontSize: '18px', fontWeight: 'bold', color: '#fff' }}>Applying Feedback...</div>
|
|
|
|
<div style={{ color: '#999', fontSize: '14px' }}>
|
|
Regenerating {shape.props.pageFeedback.filter(f => !f.approved && f.feedbackText).length} pages
|
|
</div>
|
|
|
|
{shape.props.currentGeneratingPage > 0 && (
|
|
<div style={{ color: MycroZineGeneratorShape.PRIMARY_COLOR, fontSize: '14px' }}>
|
|
Currently updating page {shape.props.currentGeneratingPage}...
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
|
|
// ========== COMPLETE PHASE ==========
|
|
const renderCompletePhase = () => {
|
|
const pages = shape.props.finalPages.length > 0 ? shape.props.finalPages : shape.props.draftPages
|
|
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', gap: '12px' }}>
|
|
<div style={{ textAlign: 'center' }}>
|
|
<div style={{ fontSize: '24px', marginBottom: '4px' }}>🍄</div>
|
|
<div style={{ fontSize: '16px', fontWeight: 'bold', color: MycroZineGeneratorShape.PRIMARY_COLOR }}>
|
|
Zine Complete!
|
|
</div>
|
|
<div style={{ color: '#999', fontSize: '13px' }}>
|
|
"{shape.props.title || shape.props.topic}"
|
|
</div>
|
|
</div>
|
|
|
|
{/* Print preview - 2x4 grid */}
|
|
<div style={{
|
|
flex: 1,
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(4, 1fr)',
|
|
gridTemplateRows: 'repeat(2, 1fr)',
|
|
gap: '2px',
|
|
backgroundColor: '#fff',
|
|
padding: '4px',
|
|
borderRadius: '4px',
|
|
}}>
|
|
{pages.map((page) => (
|
|
<img
|
|
key={page.pageNumber}
|
|
src={page.imageUrl}
|
|
alt={`Page ${page.pageNumber}`}
|
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Action buttons */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
<button
|
|
onClick={generatePrintLayout}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
style={buttonStyle}
|
|
disabled={shape.props.isLoading}
|
|
>
|
|
📥 Download Print-Ready PNG
|
|
</button>
|
|
<button
|
|
onClick={saveAsTemplate}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
style={{ ...buttonStyle, backgroundColor: '#333' }}
|
|
>
|
|
💾 Save as Template
|
|
</button>
|
|
<button
|
|
onClick={() => updateProps({
|
|
phase: 'ideation',
|
|
topic: '',
|
|
title: '',
|
|
contentOutline: null,
|
|
ideationMessages: [],
|
|
draftPages: [],
|
|
pageFeedback: [],
|
|
finalPages: [],
|
|
printLayoutUrl: null,
|
|
zineId: `zine_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
})}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
style={{ ...buttonStyle, backgroundColor: '#1a1a1a', color: '#999' }}
|
|
>
|
|
🔄 Start New Zine
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Phase indicator
|
|
const phaseLabels: Record<ZinePhase, string> = {
|
|
ideation: '1. Ideation',
|
|
drafts: '2. Drafts',
|
|
feedback: '3. Feedback',
|
|
finalizing: '4. Finalizing',
|
|
complete: '5. Complete',
|
|
}
|
|
|
|
return (
|
|
<HTMLContainer id={shape.id}>
|
|
<StandardizedToolWrapper
|
|
title="🍄 MycroZine Generator"
|
|
primaryColor={MycroZineGeneratorShape.PRIMARY_COLOR}
|
|
isSelected={isSelected}
|
|
width={shape.props.w}
|
|
height={shape.props.h}
|
|
onClose={handleClose}
|
|
onMinimize={handleMinimize}
|
|
isMinimized={isMinimized}
|
|
onMaximize={toggleMaximize}
|
|
isMaximized={isMaximized}
|
|
editor={editor}
|
|
shapeId={shape.id}
|
|
tags={shape.props.tags || []}
|
|
onTagsChange={handleTagsChange}
|
|
tagsEditable={true}
|
|
isPinnedToView={shape.props.pinnedToView}
|
|
onPinToggle={handlePinToggle}
|
|
headerContent={
|
|
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
🍄 MycroZine
|
|
<span style={{
|
|
fontSize: '10px',
|
|
backgroundColor: MycroZineGeneratorShape.SECONDARY_COLOR,
|
|
padding: '2px 6px',
|
|
borderRadius: '10px',
|
|
color: MycroZineGeneratorShape.PRIMARY_COLOR,
|
|
}}>
|
|
{phaseLabels[shape.props.phase]}
|
|
</span>
|
|
</span>
|
|
}
|
|
>
|
|
<div style={{
|
|
flex: 1,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
padding: '12px',
|
|
backgroundColor: '#0a0a0a',
|
|
color: '#fff',
|
|
overflow: 'hidden',
|
|
}}>
|
|
{/* Error display */}
|
|
{shape.props.error && (
|
|
<div style={{
|
|
padding: '8px 12px',
|
|
backgroundColor: '#331111',
|
|
border: '1px solid #663333',
|
|
borderRadius: '6px',
|
|
color: '#ff6666',
|
|
fontSize: '12px',
|
|
marginBottom: '12px',
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
}}>
|
|
<span>{shape.props.error}</span>
|
|
<button
|
|
onClick={() => updateProps({ error: null })}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
style={{ background: 'none', border: 'none', color: '#ff6666', cursor: 'pointer' }}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{renderPhaseContent()}
|
|
</div>
|
|
</StandardizedToolWrapper>
|
|
</HTMLContainer>
|
|
)
|
|
}
|
|
|
|
override indicator(shape: IMycroZineGenerator) {
|
|
return (
|
|
<rect
|
|
width={shape.props.w}
|
|
height={shape.props.h}
|
|
rx={6}
|
|
/>
|
|
)
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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<string> {
|
|
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<string | undefined> {
|
|
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',
|
|
}
|