Merge pull request #1 from riccardogiorato/refactoring
Add add-page/delete-page APIs and enhance comic generation with prompt refactoring
This commit is contained in:
@@ -0,0 +1,228 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@clerk/nextjs/server";
|
||||||
|
import Together from "together-ai";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { pages } from "@/lib/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
updatePage,
|
||||||
|
createPage,
|
||||||
|
getNextPageNumber,
|
||||||
|
getStoryWithPagesBySlug,
|
||||||
|
getLastPageImage,
|
||||||
|
} from "@/lib/db-actions";
|
||||||
|
import { freeTierRateLimit } from "@/lib/rate-limit";
|
||||||
|
import { uploadImageToS3 } from "@/lib/s3-upload";
|
||||||
|
import { buildComicPrompt } from "@/lib/prompt";
|
||||||
|
|
||||||
|
const NEW_MODEL = false;
|
||||||
|
|
||||||
|
const IMAGE_MODEL = NEW_MODEL
|
||||||
|
? "google/gemini-3-pro-image"
|
||||||
|
: "google/flash-image-2.5";
|
||||||
|
|
||||||
|
const FIXED_DIMENSIONS = NEW_MODEL
|
||||||
|
? { width: 896, height: 1200 }
|
||||||
|
: { width: 864, height: 1184 };
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { userId } = await auth();
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Authentication required" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { storyId, pageId, prompt, characterImages = [] } = await request.json();
|
||||||
|
|
||||||
|
if (!storyId || !prompt) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing required fields: storyId and prompt" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the story and all its pages
|
||||||
|
const storyData = await getStoryWithPagesBySlug(storyId);
|
||||||
|
if (!storyData) {
|
||||||
|
return NextResponse.json({ error: "Story not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { story, pages } = storyData;
|
||||||
|
|
||||||
|
// Check ownership
|
||||||
|
if (story.userId !== userId) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply rate limiting for free tier
|
||||||
|
const hasApiKey = request.headers.get('x-api-key');
|
||||||
|
if (!hasApiKey) {
|
||||||
|
const { success, reset } = await freeTierRateLimit.limit(userId);
|
||||||
|
if (!success) {
|
||||||
|
const resetDate = new Date(reset);
|
||||||
|
const timeUntilReset = Math.ceil(
|
||||||
|
(reset - Date.now()) / (1000 * 60 * 60 * 24)
|
||||||
|
);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `Free tier limit reached. You can generate 1 comic per week. Try again in ${timeUntilReset} day(s), or provide your own API key.`,
|
||||||
|
resetDate: resetDate.toISOString(),
|
||||||
|
isRateLimited: true,
|
||||||
|
},
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let page;
|
||||||
|
let pageNumber;
|
||||||
|
let isRedraw = false;
|
||||||
|
|
||||||
|
if (pageId) {
|
||||||
|
// Redraw mode: update existing page
|
||||||
|
isRedraw = true;
|
||||||
|
const storyData = await getStoryWithPagesBySlug(storyId);
|
||||||
|
if (!storyData) {
|
||||||
|
return NextResponse.json({ error: "Story not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingPage = storyData.pages.find(p => p.id === pageId);
|
||||||
|
if (!existingPage) {
|
||||||
|
return NextResponse.json({ error: "Page not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
page = existingPage;
|
||||||
|
pageNumber = existingPage.pageNumber;
|
||||||
|
} else {
|
||||||
|
// Add new page mode
|
||||||
|
pageNumber = await getNextPageNumber(story.id);
|
||||||
|
page = await createPage({
|
||||||
|
storyId: story.id,
|
||||||
|
pageNumber,
|
||||||
|
prompt,
|
||||||
|
characterImageUrls: characterImages,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const dimensions = FIXED_DIMENSIONS;
|
||||||
|
|
||||||
|
// Collect reference images: previous page + story characters + current characters
|
||||||
|
let referenceImages: string[] = [];
|
||||||
|
|
||||||
|
// Get previous page image for style consistency (unless it's page 1)
|
||||||
|
if (pageNumber > 1) {
|
||||||
|
if (isRedraw) {
|
||||||
|
// For redraw, get all pages and find the previous page's image
|
||||||
|
const storyData = await getStoryWithPagesBySlug(storyId);
|
||||||
|
if (storyData) {
|
||||||
|
const previousPage = storyData.pages.find(p => p.pageNumber === pageNumber - 1);
|
||||||
|
if (previousPage?.generatedImageUrl) {
|
||||||
|
referenceImages.push(previousPage.generatedImageUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For new page, use the last page image
|
||||||
|
const lastPageImage = await getLastPageImage(story.id);
|
||||||
|
if (lastPageImage) {
|
||||||
|
referenceImages.push(lastPageImage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use only the character images sent from the frontend (user's selection)
|
||||||
|
// These are already the most recent/relevant characters the user wants to use
|
||||||
|
referenceImages.push(...characterImages);
|
||||||
|
|
||||||
|
// Build the prompt with continuation context
|
||||||
|
const previousPages = pages.map(p => ({
|
||||||
|
prompt: p.prompt,
|
||||||
|
characterImages: p.characterImageUrls,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fullPrompt = buildComicPrompt({
|
||||||
|
prompt,
|
||||||
|
style: story.style,
|
||||||
|
characterImages,
|
||||||
|
isAddPage: true,
|
||||||
|
previousPages,
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new Together({ apiKey: process.env.TOGETHER_API_KEY_DEFAULT });
|
||||||
|
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await client.images.generate({
|
||||||
|
model: IMAGE_MODEL,
|
||||||
|
prompt: fullPrompt,
|
||||||
|
width: dimensions.width,
|
||||||
|
height: dimensions.height,
|
||||||
|
temperature: 0.1,
|
||||||
|
reference_images: referenceImages.length > 0 ? referenceImages : undefined,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Together AI API error:", error);
|
||||||
|
|
||||||
|
if (error instanceof Error && "status" in error) {
|
||||||
|
const status = (error as any).status;
|
||||||
|
if (status === 402) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "Insufficient API credits.",
|
||||||
|
errorType: "credit_limit",
|
||||||
|
},
|
||||||
|
{ status: 402 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: error.message || `Failed to generate image: ${status}`,
|
||||||
|
errorType: "api_error",
|
||||||
|
},
|
||||||
|
{ status: status || 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `Internal server error: ${
|
||||||
|
error instanceof Error ? error.message : "Unknown error"
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.data || !response.data[0] || !response.data[0].url) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "No image URL in response" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageUrl = response.data[0].url;
|
||||||
|
const s3Key = `${story.id}/page-${page.pageNumber}-${Date.now()}.jpg`;
|
||||||
|
const s3ImageUrl = await uploadImageToS3(imageUrl, s3Key);
|
||||||
|
|
||||||
|
await updatePage(page.id, s3ImageUrl);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
imageUrl: s3ImageUrl,
|
||||||
|
pageId: page.id,
|
||||||
|
pageNumber: page.pageNumber,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in add-page API:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `Internal server error: ${
|
||||||
|
error instanceof Error ? error.message : "Unknown error"
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@clerk/nextjs/server";
|
||||||
|
import { getStoryWithPagesBySlug, deletePage } from "@/lib/db-actions";
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { userId } = await auth();
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Authentication required" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { storySlug, pageId } = await request.json();
|
||||||
|
|
||||||
|
if (!storySlug || !pageId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing required fields: storySlug and pageId" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the story to check ownership
|
||||||
|
const storyData = await getStoryWithPagesBySlug(storySlug);
|
||||||
|
if (!storyData) {
|
||||||
|
return NextResponse.json({ error: "Story not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { story, pages } = storyData;
|
||||||
|
|
||||||
|
// Check ownership
|
||||||
|
if (story.userId !== userId) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if page exists and belongs to the story
|
||||||
|
const pageExists = pages.some(p => p.id === pageId);
|
||||||
|
if (!pageExists) {
|
||||||
|
return NextResponse.json({ error: "Page not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't allow deleting the last page
|
||||||
|
if (pages.length <= 1) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Cannot delete the last page of a story" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await deletePage(pageId);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting page:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `Internal server error: ${
|
||||||
|
error instanceof Error ? error.message : "Unknown error"
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+147
-70
@@ -3,14 +3,18 @@ import Together from "together-ai";
|
|||||||
import { auth } from "@clerk/nextjs/server";
|
import { auth } from "@clerk/nextjs/server";
|
||||||
import {
|
import {
|
||||||
updatePage,
|
updatePage,
|
||||||
|
updateStory,
|
||||||
createStory,
|
createStory,
|
||||||
createPage,
|
createPage,
|
||||||
getNextPageNumber,
|
getNextPageNumber,
|
||||||
getStoryById,
|
getStoryById,
|
||||||
|
getLastPageImage,
|
||||||
|
getStoryCharacterImages,
|
||||||
} from "@/lib/db-actions";
|
} from "@/lib/db-actions";
|
||||||
import { freeTierRateLimit } from "@/lib/rate-limit";
|
import { freeTierRateLimit } from "@/lib/rate-limit";
|
||||||
import { COMIC_STYLES } from "@/lib/constants";
|
import { COMIC_STYLES } from "@/lib/constants";
|
||||||
import { uploadImageToS3 } from "@/lib/s3-upload";
|
import { uploadImageToS3 } from "@/lib/s3-upload";
|
||||||
|
import { buildComicPrompt } from "@/lib/prompt";
|
||||||
|
|
||||||
const NEW_MODEL = false;
|
const NEW_MODEL = false;
|
||||||
|
|
||||||
@@ -22,6 +26,8 @@ const FIXED_DIMENSIONS = NEW_MODEL
|
|||||||
? { width: 896, height: 1200 }
|
? { width: 896, height: 1200 }
|
||||||
: { width: 864, height: 1184 };
|
: { width: 864, height: 1184 };
|
||||||
|
|
||||||
|
const TEXT_MODEL = "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { userId } = await auth();
|
const { userId } = await auth();
|
||||||
@@ -88,9 +94,11 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
let page;
|
let page;
|
||||||
let story;
|
let story;
|
||||||
|
let referenceImages: string[] = [];
|
||||||
|
|
||||||
if (storyId) {
|
if (storyId) {
|
||||||
const story = await getStoryById(storyId);
|
// Continuation: get previous page image and story character images
|
||||||
|
story = await getStoryById(storyId);
|
||||||
if (!story) {
|
if (!story) {
|
||||||
return NextResponse.json({ error: "Story not found" }, { status: 404 });
|
return NextResponse.json({ error: "Story not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
@@ -102,7 +110,20 @@ export async function POST(request: NextRequest) {
|
|||||||
prompt,
|
prompt,
|
||||||
characterImageUrls: characterImages,
|
characterImageUrls: characterImages,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get previous page image for style consistency (unless it's page 1)
|
||||||
|
if (nextPageNumber > 1) {
|
||||||
|
const lastPageImage = await getLastPageImage(storyId);
|
||||||
|
if (lastPageImage) {
|
||||||
|
referenceImages.push(lastPageImage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For continuation pages, character images are sent from frontend
|
||||||
|
// No need to fetch separately - frontend handles selection
|
||||||
} else {
|
} else {
|
||||||
|
// New story: no previous page reference
|
||||||
|
// Create story with temporary title, will update with generated title
|
||||||
story = await createStory({
|
story = await createStory({
|
||||||
title: prompt.length > 50 ? prompt.substring(0, 50) + "..." : prompt,
|
title: prompt.length > 50 ? prompt.substring(0, 50) + "..." : prompt,
|
||||||
description: undefined,
|
description: undefined,
|
||||||
@@ -118,78 +139,104 @@ export async function POST(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use only the character images sent from the frontend
|
||||||
|
referenceImages.push(...characterImages);
|
||||||
|
|
||||||
const dimensions = FIXED_DIMENSIONS;
|
const dimensions = FIXED_DIMENSIONS;
|
||||||
const styleInfo = COMIC_STYLES.find((s) => s.id === style);
|
|
||||||
const styleDesc = styleInfo?.prompt || COMIC_STYLES[2].prompt;
|
|
||||||
|
|
||||||
const continuationContext =
|
const fullPrompt = buildComicPrompt({
|
||||||
isContinuation && previousContext
|
prompt,
|
||||||
? `\nCONTINUATION CONTEXT:\nThis is a continuation of an existing story. The previous page showed: ${previousContext}\nMaintain visual consistency with the previous panels. Continue the narrative naturally.\n`
|
style,
|
||||||
: "";
|
characterImages,
|
||||||
|
isContinuation,
|
||||||
let characterSection = "";
|
previousContext,
|
||||||
if (characterImages.length > 0) {
|
});
|
||||||
if (characterImages.length === 1) {
|
|
||||||
characterSection = `
|
|
||||||
CRITICAL FACE CONSISTENCY INSTRUCTIONS:
|
|
||||||
- REFERENCE CHARACTER: Use the uploaded image as EXACT reference for the protagonist's face and appearance
|
|
||||||
- FACE MATCHING: The character's face must be IDENTICAL to the reference image - same eyes, nose, mouth, hair, facial structure
|
|
||||||
- APPEARANCE PRESERVATION: Maintain exact skin tone, hair color/style, eye color, and distinctive facial features
|
|
||||||
- CHARACTER CONSISTENCY: This exact same character must appear in ALL 5 panels with the same face throughout
|
|
||||||
- STYLE APPLICATION: Apply ${style} comic art style to the body/pose/action but KEEP THE FACE EXACTLY AS IN THE REFERENCE IMAGE
|
|
||||||
- NO VARIATION: Do not alter, modify, or change the character's face in any way from the reference`;
|
|
||||||
} else if (characterImages.length === 2) {
|
|
||||||
characterSection = `
|
|
||||||
CRITICAL DUAL CHARACTER FACE CONSISTENCY INSTRUCTIONS:
|
|
||||||
- CHARACTER 1 REFERENCE: Use the FIRST uploaded image as EXACT reference for Character 1's face and appearance
|
|
||||||
- CHARACTER 2 REFERENCE: Use the SECOND uploaded image as EXACT reference for Character 2's face and appearance
|
|
||||||
- FACE MATCHING: Both characters' faces must be IDENTICAL to their respective reference images
|
|
||||||
- VISUAL DISTINCTION: Keep both characters clearly visually distinct with their unique faces, hair, and features
|
|
||||||
- CONSISTENT PRESENCE: Both characters must appear together in at least 4 of the 5 panels
|
|
||||||
- STYLE APPLICATION: Apply ${style} comic art style while maintaining EXACT facial features from references
|
|
||||||
- NO FACE VARIATION: Never alter or modify either character's face from their reference images`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const systemPrompt = `Professional comic book page illustration.
|
|
||||||
${continuationContext}
|
|
||||||
${characterSection}
|
|
||||||
|
|
||||||
CHARACTER CONSISTENCY RULES (HIGHEST PRIORITY):
|
|
||||||
- If reference images are provided, the characters' FACES must be 100% identical to the reference images
|
|
||||||
- Never change hair color, eye color, facial structure, or distinctive features
|
|
||||||
- Apply comic style to body/pose/action but preserve exact facial appearance
|
|
||||||
- Same character must look identical across all panels they appear in
|
|
||||||
|
|
||||||
TEXT AND LETTERING (CRITICAL):
|
|
||||||
- All text in speech bubbles must be PERFECTLY CLEAR, LEGIBLE, and correctly spelled
|
|
||||||
- Use bold clean comic book lettering, large and easy to read
|
|
||||||
- Speech bubbles: crisp white fill, solid black outline, pointed tail toward speaker
|
|
||||||
- Keep dialogue SHORT: maximum 1-2 sentences per bubble
|
|
||||||
- NO blurry, warped, or unreadable text
|
|
||||||
|
|
||||||
PAGE LAYOUT:
|
|
||||||
5-panel comic page arranged as:
|
|
||||||
[Panel 1] [Panel 2] — top row, 2 equal panels
|
|
||||||
[ Panel 3 ] — middle row, 1 large cinematic hero panel
|
|
||||||
[Panel 4] [Panel 5] — bottom row, 2 equal panels
|
|
||||||
- Solid black panel borders with clean white gutters between panels
|
|
||||||
- Each panel clearly separated and distinct
|
|
||||||
|
|
||||||
ART STYLE:
|
|
||||||
${styleDesc}
|
|
||||||
${characterSection}
|
|
||||||
|
|
||||||
COMPOSITION:
|
|
||||||
- Vary camera angles across panels: close-up, medium shot, wide establishing shot
|
|
||||||
- Natural visual flow: left-to-right, top-to-bottom reading order
|
|
||||||
- Dynamic character poses with clear expressive acting
|
|
||||||
- Detailed backgrounds matching the scene and mood`;
|
|
||||||
|
|
||||||
const fullPrompt = `${systemPrompt}\n\nSTORY:\n${prompt}`;
|
|
||||||
|
|
||||||
const client = new Together({ apiKey: finalApiKey });
|
const client = new Together({ apiKey: finalApiKey });
|
||||||
|
|
||||||
|
// Generate title and description in parallel with image generation (only for new stories)
|
||||||
|
let titleGenerationPromise: Promise<{
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}> | null = null;
|
||||||
|
if (!storyId) {
|
||||||
|
titleGenerationPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const titlePrompt = `Based on this comic book prompt, generate a compelling title and description for the comic book.
|
||||||
|
|
||||||
|
Prompt: "${prompt}"
|
||||||
|
Style: ${COMIC_STYLES.find((s) => s.id === style)?.name || style}
|
||||||
|
|
||||||
|
Generate:
|
||||||
|
1. A catchy, engaging title (maximum 60 characters)
|
||||||
|
2. A brief description (2-3 sentences, maximum 200 characters)
|
||||||
|
|
||||||
|
Format your response as JSON:
|
||||||
|
{
|
||||||
|
"title": "Title here",
|
||||||
|
"description": "Description here"
|
||||||
|
}
|
||||||
|
|
||||||
|
Only return the JSON, no other text.`;
|
||||||
|
|
||||||
|
const textResponse = await client.chat.completions.create({
|
||||||
|
model: TEXT_MODEL,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content:
|
||||||
|
"You are a creative assistant that generates compelling comic book titles and descriptions. Always respond with valid JSON only.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: titlePrompt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
temperature: 0.8,
|
||||||
|
max_tokens: 300,
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = textResponse.choices[0]?.message?.content?.trim();
|
||||||
|
if (!content) {
|
||||||
|
throw new Error("No response from text generation");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract JSON from response (in case there's extra text)
|
||||||
|
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||||||
|
if (!jsonMatch) {
|
||||||
|
throw new Error("No JSON found in response");
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(jsonMatch[0]);
|
||||||
|
const rawTitle =
|
||||||
|
parsed.title?.trim() ||
|
||||||
|
(prompt.length > 50 ? prompt.substring(0, 50) + "..." : prompt);
|
||||||
|
const rawDescription = parsed.description?.trim();
|
||||||
|
|
||||||
|
// Enforce character limits
|
||||||
|
const title =
|
||||||
|
rawTitle.length > 60 ? rawTitle.substring(0, 57) + "..." : rawTitle;
|
||||||
|
const description =
|
||||||
|
rawDescription && rawDescription.length > 200
|
||||||
|
? rawDescription.substring(0, 197) + "..."
|
||||||
|
: rawDescription;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description: description || undefined,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating title and description:", error);
|
||||||
|
// Fallback to prompt-based title
|
||||||
|
return {
|
||||||
|
title:
|
||||||
|
prompt.length > 50 ? prompt.substring(0, 50) + "..." : prompt,
|
||||||
|
description: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
let response;
|
let response;
|
||||||
try {
|
try {
|
||||||
response = await client.images.generate({
|
response = await client.images.generate({
|
||||||
@@ -199,7 +246,7 @@ COMPOSITION:
|
|||||||
height: dimensions.height,
|
height: dimensions.height,
|
||||||
temperature: 0.1, // Lower temperature for more consistent face matching
|
temperature: 0.1, // Lower temperature for more consistent face matching
|
||||||
reference_images:
|
reference_images:
|
||||||
characterImages.length > 0 ? characterImages : undefined,
|
referenceImages.length > 0 ? referenceImages : undefined,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Together AI API error:", error);
|
console.error("Together AI API error:", error);
|
||||||
@@ -245,9 +292,37 @@ COMPOSITION:
|
|||||||
const imageUrl = response.data[0].url;
|
const imageUrl = response.data[0].url;
|
||||||
|
|
||||||
// Upload image to S3 for permanent storage
|
// Upload image to S3 for permanent storage
|
||||||
const s3Key = `${storyId || story!.id}/page-${page.pageNumber}-${Date.now()}.jpg`;
|
const s3Key = `${storyId || story!.id}/page-${
|
||||||
|
page.pageNumber
|
||||||
|
}-${Date.now()}.jpg`;
|
||||||
const s3ImageUrl = await uploadImageToS3(imageUrl, s3Key);
|
const s3ImageUrl = await uploadImageToS3(imageUrl, s3Key);
|
||||||
|
|
||||||
|
// Wait for title/description generation if it's a new story
|
||||||
|
let generatedTitle: string | undefined;
|
||||||
|
let generatedDescription: string | undefined;
|
||||||
|
if (titleGenerationPromise) {
|
||||||
|
const titleData = await titleGenerationPromise;
|
||||||
|
generatedTitle = titleData.title;
|
||||||
|
generatedDescription = titleData.description;
|
||||||
|
|
||||||
|
// Update story with generated title and description
|
||||||
|
try {
|
||||||
|
await updateStory(story!.id, {
|
||||||
|
title: generatedTitle,
|
||||||
|
description: generatedDescription,
|
||||||
|
});
|
||||||
|
// Update story object for response
|
||||||
|
story = {
|
||||||
|
...story,
|
||||||
|
title: generatedTitle,
|
||||||
|
description: generatedDescription,
|
||||||
|
};
|
||||||
|
} catch (dbError) {
|
||||||
|
console.error("Error updating story title/description:", dbError);
|
||||||
|
// Continue even if update fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update page in database with S3 URL
|
// Update page in database with S3 URL
|
||||||
try {
|
try {
|
||||||
await updatePage(page.id, s3ImageUrl);
|
await updatePage(page.id, s3ImageUrl);
|
||||||
@@ -267,6 +342,8 @@ COMPOSITION:
|
|||||||
storySlug: story!.slug,
|
storySlug: story!.slug,
|
||||||
pageId: page.id,
|
pageId: page.id,
|
||||||
pageNumber: page.pageNumber,
|
pageNumber: page.pageNumber,
|
||||||
|
title: generatedTitle || story!.title,
|
||||||
|
description: generatedDescription || story!.description,
|
||||||
};
|
};
|
||||||
|
|
||||||
return NextResponse.json(responseData);
|
return NextResponse.json(responseData);
|
||||||
|
|||||||
@@ -10,19 +10,23 @@ export async function GET(
|
|||||||
{ params }: { params: Promise<{ storySlug: string }> }
|
{ params }: { params: Promise<{ storySlug: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { userId } = await auth();
|
const authResult = await auth();
|
||||||
|
const { userId } = authResult;
|
||||||
|
|
||||||
if (!userId) {
|
console.log('API: auth result:', authResult);
|
||||||
return NextResponse.json(
|
console.log('API: userId type:', typeof userId, 'value:', userId);
|
||||||
{ error: "Authentication required" },
|
console.log('API: timestamp:', new Date().toISOString());
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { storySlug: slug } = await params;
|
const { storySlug: slug } = await params;
|
||||||
|
|
||||||
// Special case: if slug is "all", return user's stories for debugging
|
// Special case: if slug is "all", return user's stories for debugging
|
||||||
if (slug === "all") {
|
if (slug === "all") {
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Authentication required for this endpoint" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
const userStories = await db.select().from(stories).where(eq(stories.userId, userId));
|
const userStories = await db.select().from(stories).where(eq(stories.userId, userId));
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: "User stories",
|
message: "User stories",
|
||||||
@@ -47,14 +51,14 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if the story belongs to the authenticated user
|
// Check if the story belongs to the authenticated user
|
||||||
if (result.story.userId !== userId) {
|
const isOwner = userId ? result.story.userId === userId : false;
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Access denied" },
|
|
||||||
{ status: 403 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(result);
|
// Return the story data with ownership information
|
||||||
|
const responseData = {
|
||||||
|
...result,
|
||||||
|
isOwner,
|
||||||
|
};
|
||||||
|
return NextResponse.json(responseData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching story:", error);
|
console.error("Error fetching story:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { auth } from "@clerk/nextjs/server";
|
import { auth } from "@clerk/nextjs/server";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { stories, pages } from "@/lib/schema";
|
import { stories, pages } from "@/lib/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, desc, sql } from "drizzle-orm";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@@ -15,22 +15,24 @@ export async function GET() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all stories for the user with their first page
|
// Get all stories for the user with their pages
|
||||||
const userStories = await db
|
const userStories = await db
|
||||||
.select({
|
.select({
|
||||||
id: stories.id,
|
id: stories.id,
|
||||||
title: stories.title,
|
title: stories.title,
|
||||||
slug: stories.slug,
|
slug: stories.slug,
|
||||||
|
style: stories.style,
|
||||||
createdAt: stories.createdAt,
|
createdAt: stories.createdAt,
|
||||||
pageCount: pages.pageNumber,
|
pageCount: pages.pageNumber,
|
||||||
coverImage: pages.generatedImageUrl,
|
coverImage: pages.generatedImageUrl,
|
||||||
|
pageCreatedAt: pages.createdAt,
|
||||||
|
pageUpdatedAt: pages.updatedAt,
|
||||||
})
|
})
|
||||||
.from(stories)
|
.from(stories)
|
||||||
.leftJoin(pages, eq(stories.id, pages.storyId))
|
.leftJoin(pages, eq(stories.id, pages.storyId))
|
||||||
.where(eq(stories.userId, userId))
|
.where(eq(stories.userId, userId));
|
||||||
.orderBy(stories.createdAt);
|
|
||||||
|
|
||||||
// Group by story and find the max page number and first page image
|
// Group by story and find the max page number, first page image, and most recent update
|
||||||
const storyMap = new Map();
|
const storyMap = new Map();
|
||||||
|
|
||||||
userStories.forEach((row) => {
|
userStories.forEach((row) => {
|
||||||
@@ -40,9 +42,11 @@ export async function GET() {
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
slug: row.slug,
|
slug: row.slug,
|
||||||
|
style: row.style,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
pageCount: 0,
|
pageCount: 0,
|
||||||
coverImage: null,
|
coverImage: null,
|
||||||
|
lastUpdated: row.createdAt, // Default to story creation date
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,10 +57,23 @@ export async function GET() {
|
|||||||
if (row.pageCount === 1 && row.coverImage) {
|
if (row.pageCount === 1 && row.coverImage) {
|
||||||
story.coverImage = row.coverImage;
|
story.coverImage = row.coverImage;
|
||||||
}
|
}
|
||||||
|
// Track the most recent page update
|
||||||
|
if (row.pageUpdatedAt && row.pageUpdatedAt > story.lastUpdated) {
|
||||||
|
story.lastUpdated = row.pageUpdatedAt;
|
||||||
|
} else if (row.pageCreatedAt && row.pageCreatedAt > story.lastUpdated) {
|
||||||
|
story.lastUpdated = row.pageCreatedAt;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const storiesWithCovers = Array.from(storyMap.values());
|
const storiesWithCovers = Array.from(storyMap.values());
|
||||||
|
|
||||||
|
// Sort by most recently updated (stories with newest pages first)
|
||||||
|
storiesWithCovers.sort((a, b) => {
|
||||||
|
const aTime = new Date(a.lastUpdated).getTime();
|
||||||
|
const bTime = new Date(b.lastUpdated).getTime();
|
||||||
|
return bTime - aTime; // Most recent first
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
stories: storiesWithCovers
|
stories: storiesWithCovers
|
||||||
});
|
});
|
||||||
|
|||||||
+41
-296
@@ -1,303 +1,48 @@
|
|||||||
"use client"
|
import { Metadata } from "next";
|
||||||
|
import { getStoryWithPagesBySlug } from "@/lib/db-actions";
|
||||||
|
import { StoryEditorClient } from "./story-editor-client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
export async function generateMetadata({
|
||||||
import { useParams } from "next/navigation"
|
params,
|
||||||
import { useToast } from "@/hooks/use-toast"
|
}: {
|
||||||
import { EditorToolbar } from "@/components/editor/editor-toolbar"
|
params: Promise<{ storySlug: string }>;
|
||||||
import { PageSidebar } from "@/components/editor/page-sidebar"
|
}): Promise<Metadata> {
|
||||||
import { ComicCanvas } from "@/components/editor/comic-canvas"
|
const { storySlug: slug } = await params;
|
||||||
import { ApiKeyModal } from "@/components/api-key-modal"
|
|
||||||
import { PageInfoSheet } from "@/components/editor/page-info-sheet"
|
|
||||||
import { GeneratePageModal } from "@/components/editor/generate-page-modal"
|
|
||||||
|
|
||||||
import { useS3Upload } from "next-s3-upload"
|
try {
|
||||||
|
const result = await getStoryWithPagesBySlug(slug);
|
||||||
|
|
||||||
interface PageData {
|
if (!result) {
|
||||||
id: number // pageNumber for component compatibility
|
return {
|
||||||
title: string
|
title: "Story Not Found | MakeComics",
|
||||||
image: string
|
description: "The requested comic story could not be found.",
|
||||||
prompt: string
|
};
|
||||||
characterUploads?: string[]
|
}
|
||||||
style: string
|
|
||||||
dbId?: string // actual database UUID
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StoryData {
|
const { story } = result;
|
||||||
id: string
|
const title = `${story.title} | MakeComics`;
|
||||||
title: string
|
const description =
|
||||||
description?: string | null
|
story.description ||
|
||||||
userId?: string | null
|
`${story.title} - Create your own comic book with MakeComics`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
openGraph: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating metadata:", error);
|
||||||
|
return {
|
||||||
|
title: "MakeComics",
|
||||||
|
description: "Create your own comic book with MakeComics",
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StoryEditorPage() {
|
export default function StoryEditorPage() {
|
||||||
const params = useParams()
|
return <StoryEditorClient />;
|
||||||
const slug = params.storySlug as string
|
}
|
||||||
|
|
||||||
const [story, setStory] = useState<StoryData | null>(null)
|
|
||||||
const [pages, setPages] = useState<PageData[]>([])
|
|
||||||
const [currentPage, setCurrentPage] = useState(0)
|
|
||||||
const [showApiModal, setShowApiModal] = useState(false)
|
|
||||||
const [showInfoSheet, setShowInfoSheet] = useState(false)
|
|
||||||
const [showGenerateModal, setShowGenerateModal] = useState(false)
|
|
||||||
const [loadingPageId, setLoadingPageId] = useState<number | null>(null)
|
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
|
||||||
const [existingCharacterImages, setExistingCharacterImages] = useState<string[]>([])
|
|
||||||
const { uploadToS3 } = useS3Upload()
|
|
||||||
const { toast } = useToast()
|
|
||||||
|
|
||||||
// Load story and pages from API
|
|
||||||
useEffect(() => {
|
|
||||||
const loadStoryData = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/stories/${slug}`)
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Story not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
const { story: storyData, pages: pagesData } = result
|
|
||||||
|
|
||||||
setStory(storyData)
|
|
||||||
setPages(pagesData.map((page: any) => ({
|
|
||||||
id: page.pageNumber,
|
|
||||||
title: storyData.title,
|
|
||||||
image: page.generatedImageUrl || "",
|
|
||||||
prompt: page.prompt,
|
|
||||||
characterUploads: page.characterImageUrls,
|
|
||||||
style: storyData.style || "noir",
|
|
||||||
dbId: page.id,
|
|
||||||
})))
|
|
||||||
|
|
||||||
// Load existing character images for reuse
|
|
||||||
const uniqueImages = [...new Set(pagesData.flatMap((page: any) => page.characterImageUrls || []))]
|
|
||||||
setExistingCharacterImages(uniqueImages as string[])
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error loading story:", error)
|
|
||||||
toast({
|
|
||||||
title: "Error loading story",
|
|
||||||
description: "Failed to load story data.",
|
|
||||||
variant: "destructive",
|
|
||||||
duration: 4000,
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (slug) {
|
|
||||||
loadStoryData()
|
|
||||||
}
|
|
||||||
}, [slug, toast])
|
|
||||||
|
|
||||||
// Keyboard navigation
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "ArrowRight") {
|
|
||||||
setCurrentPage((prev) => (prev < pages.length - 1 ? prev + 1 : prev))
|
|
||||||
} else if (e.key === "ArrowLeft") {
|
|
||||||
setCurrentPage((prev) => (prev > 0 ? prev - 1 : prev))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown)
|
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
|
||||||
}, [pages.length])
|
|
||||||
|
|
||||||
const handleAddPage = () => {
|
|
||||||
const storedKey = localStorage.getItem("together_api_key")
|
|
||||||
if (!storedKey && pages.length >= 1) {
|
|
||||||
setShowApiModal(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setShowGenerateModal(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleContinueStory = () => {
|
|
||||||
const storedKey = localStorage.getItem("together_api_key")
|
|
||||||
if (!storedKey) {
|
|
||||||
setShowApiModal(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setShowGenerateModal(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleApiKeyClick = () => {
|
|
||||||
setShowApiModal(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleApiKeySubmit = (key: string) => {
|
|
||||||
localStorage.setItem("together_api_key", key)
|
|
||||||
setShowApiModal(false)
|
|
||||||
const wasGenerating = showGenerateModal
|
|
||||||
if (wasGenerating) {
|
|
||||||
setShowGenerateModal(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "API key saved",
|
|
||||||
description: "Your Together API key has been saved successfully",
|
|
||||||
duration: 3000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleGeneratePage = async (data: {
|
|
||||||
prompt: string
|
|
||||||
style: string
|
|
||||||
characterFiles?: File[]
|
|
||||||
characterUrls?: string[] // For reusing existing characters
|
|
||||||
isContinuation?: boolean
|
|
||||||
}) => {
|
|
||||||
if (!story) return
|
|
||||||
|
|
||||||
setShowGenerateModal(false)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Handle new character uploads
|
|
||||||
let characterUploads: string[] = data.characterUrls || []
|
|
||||||
|
|
||||||
if (data.characterFiles && data.characterFiles.length > 0) {
|
|
||||||
const newUploads = await Promise.all(
|
|
||||||
data.characterFiles.map((file) => uploadToS3(file).then(({ url }) => url))
|
|
||||||
)
|
|
||||||
characterUploads = [...characterUploads, ...newUploads]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add loading page to UI
|
|
||||||
const nextPageNumber = pages.length + 1
|
|
||||||
const pageData: PageData = {
|
|
||||||
id: nextPageNumber,
|
|
||||||
title: story.title,
|
|
||||||
image: "",
|
|
||||||
prompt: data.prompt,
|
|
||||||
characterUploads,
|
|
||||||
style: data.style,
|
|
||||||
}
|
|
||||||
|
|
||||||
setPages([...pages, pageData])
|
|
||||||
setCurrentPage(pages.length)
|
|
||||||
setLoadingPageId(nextPageNumber)
|
|
||||||
|
|
||||||
// Generate the comic image
|
|
||||||
const apiKey = localStorage.getItem("together_api_key")
|
|
||||||
if (!apiKey) {
|
|
||||||
throw new Error("API key not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousPage = pages[pages.length - 1]
|
|
||||||
|
|
||||||
const response = await fetch("/api/generate-comic", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
storyId: story?.id,
|
|
||||||
prompt: data.prompt,
|
|
||||||
apiKey,
|
|
||||||
style: data.style,
|
|
||||||
characterImages: characterUploads,
|
|
||||||
isContinuation: data.isContinuation,
|
|
||||||
previousContext: data.isContinuation ? previousPage?.prompt : undefined,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json()
|
|
||||||
throw new Error(errorData.error || "Failed to generate image")
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
|
|
||||||
// Update page with generated image
|
|
||||||
setPages((prevPages) =>
|
|
||||||
prevPages.map((page) =>
|
|
||||||
page.id === nextPageNumber
|
|
||||||
? {
|
|
||||||
...page,
|
|
||||||
image: result.imageUrl,
|
|
||||||
dbId: result.pageId,
|
|
||||||
}
|
|
||||||
: page,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Update existing character images for future reuse
|
|
||||||
if (characterUploads.length > 0) {
|
|
||||||
const updatedImages = [...new Set([...existingCharacterImages, ...characterUploads])]
|
|
||||||
setExistingCharacterImages(updatedImages)
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "Page generated successfully",
|
|
||||||
description: `Page ${nextPageNumber} is ready`,
|
|
||||||
duration: 4000,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error generating page:", error)
|
|
||||||
toast({
|
|
||||||
title: "Generation failed",
|
|
||||||
description: error instanceof Error ? error.message : "Failed to generate comic page. Please try again.",
|
|
||||||
variant: "destructive",
|
|
||||||
duration: 4000,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Remove failed page from state
|
|
||||||
setPages((prevPages) => prevPages.filter((page) => page.id !== loadingPageId))
|
|
||||||
setCurrentPage(Math.max(0, pages.length - 1))
|
|
||||||
} finally {
|
|
||||||
setLoadingPageId(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="h-screen flex items-center justify-center bg-background">
|
|
||||||
<div className="text-white">Loading story...</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!story) {
|
|
||||||
return (
|
|
||||||
<div className="h-screen flex items-center justify-center bg-background">
|
|
||||||
<div className="text-white">Story not found</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-screen flex flex-col bg-background">
|
|
||||||
<EditorToolbar
|
|
||||||
title={story.title}
|
|
||||||
onContinueStory={handleContinueStory}
|
|
||||||
onInfoClick={() => setShowInfoSheet(true)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex-1 flex overflow-hidden">
|
|
||||||
<PageSidebar
|
|
||||||
pages={pages}
|
|
||||||
currentPage={currentPage}
|
|
||||||
onPageSelect={setCurrentPage}
|
|
||||||
onAddPage={handleAddPage}
|
|
||||||
loadingPageId={loadingPageId}
|
|
||||||
onApiKeyClick={handleApiKeyClick}
|
|
||||||
/>
|
|
||||||
<ComicCanvas page={pages[currentPage]} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ApiKeyModal isOpen={showApiModal} onClose={() => setShowApiModal(false)} onSubmit={handleApiKeySubmit} />
|
|
||||||
<GeneratePageModal
|
|
||||||
isOpen={showGenerateModal}
|
|
||||||
onClose={() => setShowGenerateModal(false)}
|
|
||||||
onGenerate={handleGeneratePage}
|
|
||||||
pageNumber={pages.length + 1}
|
|
||||||
previousCharacters={[]} // Will be updated with character reuse
|
|
||||||
previousPagePrompt={pages[pages.length - 1]?.prompt}
|
|
||||||
previousPageStyle={pages[pages.length - 1]?.style?.toLowerCase()}
|
|
||||||
existingCharacterImages={existingCharacterImages}
|
|
||||||
/>
|
|
||||||
<PageInfoSheet isOpen={showInfoSheet} onClose={() => setShowInfoSheet(false)} page={pages[currentPage]} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,453 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { useApiKey } from "@/hooks/use-api-key";
|
||||||
|
import { EditorToolbar } from "@/components/editor/editor-toolbar";
|
||||||
|
import { PageSidebar } from "@/components/editor/page-sidebar";
|
||||||
|
import { ComicCanvas } from "@/components/editor/comic-canvas";
|
||||||
|
import { ApiKeyModal } from "@/components/api-key-modal";
|
||||||
|
import { PageInfoSheet } from "@/components/editor/page-info-sheet";
|
||||||
|
import { GeneratePageModal } from "@/components/editor/generate-page-modal";
|
||||||
|
import { StoryLoader } from "@/components/ui/story-loader";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
|
interface PageData {
|
||||||
|
id: number; // pageNumber for component compatibility
|
||||||
|
title: string;
|
||||||
|
image: string;
|
||||||
|
prompt: string;
|
||||||
|
characterUploads?: string[];
|
||||||
|
style: string;
|
||||||
|
dbId?: string; // actual database UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoryData {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
style: string;
|
||||||
|
userId?: string | null;
|
||||||
|
isOwner?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StoryEditorClient() {
|
||||||
|
const params = useParams();
|
||||||
|
const slug = params.storySlug as string;
|
||||||
|
|
||||||
|
const [story, setStory] = useState<StoryData | null>(null);
|
||||||
|
const [isOwner, setIsOwner] = useState<boolean>(false);
|
||||||
|
const [pages, setPages] = useState<PageData[]>([]);
|
||||||
|
const [currentPage, setCurrentPage] = useState(0);
|
||||||
|
const [showApiModal, setShowApiModal] = useState(false);
|
||||||
|
const [showInfoSheet, setShowInfoSheet] = useState(false);
|
||||||
|
const [showGenerateModal, setShowGenerateModal] = useState(false);
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
const [pageToDelete, setPageToDelete] = useState<number | null>(null);
|
||||||
|
const [loadingPageId, setLoadingPageId] = useState<number | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [existingCharacterImages, setExistingCharacterImages] = useState<
|
||||||
|
string[]
|
||||||
|
>([]);
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [apiKey, setApiKey] = useApiKey();
|
||||||
|
|
||||||
|
// Load story and pages from API
|
||||||
|
useEffect(() => {
|
||||||
|
const loadStoryData = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/stories/${slug}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Story not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
console.log("Editor: full API response:", result);
|
||||||
|
|
||||||
|
const {
|
||||||
|
story: storyData,
|
||||||
|
pages: pagesData,
|
||||||
|
isOwner: ownerStatus,
|
||||||
|
} = result;
|
||||||
|
|
||||||
|
console.log("Editor: received story data:", storyData);
|
||||||
|
|
||||||
|
setStory(storyData);
|
||||||
|
setIsOwner(ownerStatus ?? false); // Default to false if undefined
|
||||||
|
setPages(
|
||||||
|
pagesData.map((page: any) => ({
|
||||||
|
id: page.pageNumber,
|
||||||
|
title: storyData.title,
|
||||||
|
image: page.generatedImageUrl || "",
|
||||||
|
prompt: page.prompt,
|
||||||
|
characterUploads: page.characterImageUrls,
|
||||||
|
style: storyData.style || "noir",
|
||||||
|
dbId: page.id,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Load existing character images for reuse
|
||||||
|
const uniqueImages = [
|
||||||
|
...new Set(
|
||||||
|
pagesData.flatMap((page: any) => page.characterImageUrls || [])
|
||||||
|
),
|
||||||
|
];
|
||||||
|
setExistingCharacterImages(uniqueImages as string[]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading story:", error);
|
||||||
|
toast({
|
||||||
|
title: "Error loading story",
|
||||||
|
description: "Failed to load story data.",
|
||||||
|
variant: "destructive",
|
||||||
|
duration: 4000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (slug) {
|
||||||
|
loadStoryData();
|
||||||
|
}
|
||||||
|
}, [slug, toast]);
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Don't trigger shortcuts if user is typing in an input field
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (
|
||||||
|
target.tagName === "INPUT" ||
|
||||||
|
target.tagName === "TEXTAREA" ||
|
||||||
|
target.isContentEditable
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "ArrowRight") {
|
||||||
|
setCurrentPage((prev) => (prev < pages.length - 1 ? prev + 1 : prev));
|
||||||
|
} else if (e.key === "ArrowLeft") {
|
||||||
|
setCurrentPage((prev) => (prev > 0 ? prev - 1 : prev));
|
||||||
|
} else if (e.key === "i" || e.key === "I") {
|
||||||
|
setShowInfoSheet(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [pages.length]);
|
||||||
|
|
||||||
|
const handleAddPage = () => {
|
||||||
|
if (!apiKey && pages.length >= 1) {
|
||||||
|
setShowApiModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShowGenerateModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRedrawPage = async () => {
|
||||||
|
if (!apiKey) {
|
||||||
|
setShowApiModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPageData = pages[currentPage];
|
||||||
|
if (!currentPageData) return;
|
||||||
|
|
||||||
|
setLoadingPageId(currentPage);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/add-page", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-api-key": apiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
storyId: story?.slug,
|
||||||
|
pageId: currentPageData.dbId, // Add pageId to override existing page
|
||||||
|
prompt: currentPageData.prompt,
|
||||||
|
characterImages: currentPageData.characterUploads || [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || "Failed to redraw page");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
// Update the current page with the new image
|
||||||
|
setPages((prevPages) =>
|
||||||
|
prevPages.map((page, index) =>
|
||||||
|
index === currentPage ? { ...page, image: result.imageUrl } : page
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Page redrawn successfully",
|
||||||
|
description: "The page has been regenerated with a fresh image.",
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error redrawing page:", error);
|
||||||
|
toast({
|
||||||
|
title: "Failed to redraw page",
|
||||||
|
description:
|
||||||
|
error instanceof Error ? error.message : "Failed to redraw page",
|
||||||
|
variant: "destructive",
|
||||||
|
duration: 4000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoadingPageId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApiKeyClick = () => {
|
||||||
|
setShowApiModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePage = (pageIndex: number) => {
|
||||||
|
setPageToDelete(pageIndex);
|
||||||
|
setShowDeleteDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDeletePage = async () => {
|
||||||
|
if (pageToDelete === null) return;
|
||||||
|
|
||||||
|
const pageData = pages[pageToDelete];
|
||||||
|
if (!pageData) return;
|
||||||
|
|
||||||
|
setShowDeleteDialog(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/delete-page", {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
storySlug: story?.slug,
|
||||||
|
pageId: pageData.dbId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || "Failed to delete page");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the page from state
|
||||||
|
setPages((prevPages) => {
|
||||||
|
const newPages = prevPages.filter((_, index) => index !== pageToDelete);
|
||||||
|
// Adjust currentPage if necessary
|
||||||
|
if (currentPage >= newPages.length) {
|
||||||
|
setCurrentPage(Math.max(0, newPages.length - 1));
|
||||||
|
} else if (currentPage > pageToDelete) {
|
||||||
|
setCurrentPage(currentPage - 1);
|
||||||
|
}
|
||||||
|
return newPages;
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Page deleted successfully",
|
||||||
|
description: "The page has been removed from your comic.",
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting page:", error);
|
||||||
|
toast({
|
||||||
|
title: "Failed to delete page",
|
||||||
|
description:
|
||||||
|
error instanceof Error ? error.message : "Failed to delete page",
|
||||||
|
variant: "destructive",
|
||||||
|
duration: 4000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setPageToDelete(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApiKeySubmit = (key: string) => {
|
||||||
|
setApiKey(key);
|
||||||
|
setShowApiModal(false);
|
||||||
|
const wasGenerating = showGenerateModal;
|
||||||
|
if (wasGenerating) {
|
||||||
|
setShowGenerateModal(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGeneratePage = async (data: {
|
||||||
|
prompt: string;
|
||||||
|
characterUrls?: string[];
|
||||||
|
}): Promise<void> => {
|
||||||
|
if (!apiKey) {
|
||||||
|
setShowApiModal(true);
|
||||||
|
throw new Error("API key required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new page mode
|
||||||
|
const response = await fetch("/api/add-page", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-api-key": apiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
storyId: story?.slug,
|
||||||
|
prompt: data.prompt,
|
||||||
|
characterImages: data.characterUrls || [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || "Failed to generate page");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
// Update character images list with new ones
|
||||||
|
const newCharacterUrls = data.characterUrls || [];
|
||||||
|
setExistingCharacterImages((prev) => {
|
||||||
|
const combined = [...prev, ...newCharacterUrls];
|
||||||
|
// Remove duplicates while preserving order
|
||||||
|
const unique = Array.from(new Set(combined));
|
||||||
|
return unique;
|
||||||
|
});
|
||||||
|
|
||||||
|
setPages((prevPages) => [
|
||||||
|
...prevPages,
|
||||||
|
{
|
||||||
|
id: pages.length + 1,
|
||||||
|
title: story?.title || "",
|
||||||
|
image: result.imageUrl,
|
||||||
|
prompt: data.prompt,
|
||||||
|
characterUploads: data.characterUrls || [],
|
||||||
|
style: story?.style || "noir",
|
||||||
|
dbId: result.pageId,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setCurrentPage(pages.length);
|
||||||
|
|
||||||
|
setShowGenerateModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex items-center justify-center bg-background">
|
||||||
|
<StoryLoader />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!story) {
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex items-center justify-center bg-background">
|
||||||
|
<div className="text-white">Story not found</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col bg-background">
|
||||||
|
<EditorToolbar
|
||||||
|
title={story.title}
|
||||||
|
onContinueStory={handleAddPage}
|
||||||
|
isOwner={isOwner}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
<PageSidebar
|
||||||
|
pages={pages}
|
||||||
|
currentPage={currentPage}
|
||||||
|
onPageSelect={setCurrentPage}
|
||||||
|
onAddPage={handleAddPage}
|
||||||
|
loadingPageId={loadingPageId}
|
||||||
|
onApiKeyClick={handleApiKeyClick}
|
||||||
|
isOwner={isOwner}
|
||||||
|
/>
|
||||||
|
<ComicCanvas
|
||||||
|
page={pages[currentPage]}
|
||||||
|
pageIndex={currentPage}
|
||||||
|
totalPages={pages.length}
|
||||||
|
isLoading={loadingPageId === currentPage}
|
||||||
|
isOwner={isOwner}
|
||||||
|
onInfoClick={() => setShowInfoSheet(true)}
|
||||||
|
onRedrawClick={handleRedrawPage}
|
||||||
|
onDeletePage={() => handleDeletePage(currentPage)}
|
||||||
|
onNextPage={() =>
|
||||||
|
setCurrentPage((prev) =>
|
||||||
|
prev < pages.length - 1 ? prev + 1 : prev
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onPrevPage={() =>
|
||||||
|
setCurrentPage((prev) => (prev > 0 ? prev - 1 : prev))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ApiKeyModal
|
||||||
|
isOpen={showApiModal}
|
||||||
|
onClose={() => setShowApiModal(false)}
|
||||||
|
onSubmit={handleApiKeySubmit}
|
||||||
|
/>
|
||||||
|
<GeneratePageModal
|
||||||
|
isOpen={showGenerateModal}
|
||||||
|
onClose={() => setShowGenerateModal(false)}
|
||||||
|
onGenerate={handleGeneratePage}
|
||||||
|
pageNumber={pages.length + 1}
|
||||||
|
existingCharacters={existingCharacterImages}
|
||||||
|
lastPageCharacters={
|
||||||
|
pages.length > 0 && pages[pages.length - 1]?.characterUploads
|
||||||
|
? pages[pages.length - 1].characterUploads || []
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
previousPageCharacters={
|
||||||
|
pages.length > 1 && pages[pages.length - 2]?.characterUploads
|
||||||
|
? pages[pages.length - 2].characterUploads || []
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<PageInfoSheet
|
||||||
|
isOpen={showInfoSheet}
|
||||||
|
onClose={() => setShowInfoSheet(false)}
|
||||||
|
page={pages[currentPage]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Page</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete page{" "}
|
||||||
|
{pageToDelete !== null ? pageToDelete + 1 : ""}? This action
|
||||||
|
cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={confirmDeletePage}
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+15
-24
@@ -3,8 +3,7 @@
|
|||||||
import { Navbar } from "@/components/landing/navbar"
|
import { Navbar } from "@/components/landing/navbar"
|
||||||
import { Footer } from "@/components/landing/footer"
|
import { Footer } from "@/components/landing/footer"
|
||||||
import { LandingHero } from "@/components/landing/hero-section"
|
import { LandingHero } from "@/components/landing/hero-section"
|
||||||
import { StoryInput } from "@/components/landing/story-input"
|
import { ComicCreationForm } from "@/components/landing/comic-creation-form"
|
||||||
import { CreateButton } from "@/components/landing/create-button"
|
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
@@ -43,28 +42,20 @@ export default function Home() {
|
|||||||
<div className="max-w-xl mx-auto lg:mx-0 w-full z-10">
|
<div className="max-w-xl mx-auto lg:mx-0 w-full z-10">
|
||||||
<LandingHero />
|
<LandingHero />
|
||||||
|
|
||||||
<div className="space-y-4 sm:space-y-5 mt-4 sm:mt-5">
|
<div className="space-y-4 sm:space-y-5 mt-4 sm:mt-5">
|
||||||
<div className="opacity-0 animate-fade-in-up animation-delay-100">
|
<div className="opacity-0 animate-fade-in-up animation-delay-100">
|
||||||
<StoryInput
|
<ComicCreationForm
|
||||||
prompt={prompt}
|
prompt={prompt}
|
||||||
setPrompt={setPrompt}
|
setPrompt={setPrompt}
|
||||||
style={style}
|
style={style}
|
||||||
setStyle={setStyle}
|
setStyle={setStyle}
|
||||||
characterFiles={characterFiles}
|
characterFiles={characterFiles}
|
||||||
setCharacterFiles={setCharacterFiles}
|
setCharacterFiles={setCharacterFiles}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
/>
|
setIsLoading={setIsLoading}
|
||||||
</div>
|
/>
|
||||||
<div className="opacity-0 animate-fade-in-up animation-delay-200">
|
</div>
|
||||||
<CreateButton
|
</div>
|
||||||
prompt={prompt}
|
|
||||||
style={style}
|
|
||||||
characterFiles={characterFiles}
|
|
||||||
isLoading={isLoading}
|
|
||||||
setIsLoading={setIsLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+98
-68
@@ -5,14 +5,18 @@ import { useRouter } from "next/navigation";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Plus, Loader2 } from "lucide-react";
|
import { Plus, Loader2 } from "lucide-react";
|
||||||
import { Navbar } from "@/components/landing/navbar";
|
import { Navbar } from "@/components/landing/navbar";
|
||||||
|
import { StoryLoader } from "@/components/ui/story-loader";
|
||||||
|
import { COMIC_STYLES } from "@/lib/constants";
|
||||||
|
|
||||||
interface Story {
|
interface Story {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
style: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
pageCount: number;
|
pageCount: number;
|
||||||
coverImage: string | null;
|
coverImage: string | null;
|
||||||
|
lastUpdated?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StoriesPage() {
|
export default function StoriesPage() {
|
||||||
@@ -51,10 +55,7 @@ export default function StoriesPage() {
|
|||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<main className="flex-1 flex items-center justify-center">
|
<main className="flex-1 flex items-center justify-center">
|
||||||
<div className="text-center">
|
<StoryLoader text="Loading your comic library..." />
|
||||||
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-4" />
|
|
||||||
<p className="text-muted-foreground">Loading your comic library...</p>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -85,78 +86,107 @@ export default function StoriesPage() {
|
|||||||
<div className="fixed inset-0 overflow-hidden pointer-events-none -z-10">
|
<div className="fixed inset-0 overflow-hidden pointer-events-none -z-10">
|
||||||
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-indigo/10 rounded-full blur-[120px]" />
|
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-indigo/10 rounded-full blur-[120px]" />
|
||||||
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-blue-900/10 rounded-full blur-[120px]" />
|
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-blue-900/10 rounded-full blur-[120px]" />
|
||||||
|
<div className="absolute top-[20%] right-[20%] w-[30%] h-[30%] bg-emerald/5 rounded-full blur-[100px]" />
|
||||||
|
<div className="absolute bottom-[30%] left-[15%] w-[25%] h-[25%] bg-purple-500/5 rounded-full blur-[80px]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<main className="flex-1 flex flex-col min-h-[calc(100vh-4rem)]">
|
<main className="flex-1 flex flex-col min-h-[calc(100vh-4rem)]">
|
||||||
<div className="w-full px-4 sm:px-6 lg:px-12 xl:px-20 py-4 sm:py-6 relative">
|
<div className="w-full px-4 sm:px-6 lg:px-12 xl:px-20 py-4 sm:py-6 relative">
|
||||||
<div className="max-w-7xl mx-auto w-full z-10 py-8">
|
<div className="max-w-7xl mx-auto w-full z-10 py-8">
|
||||||
{stories.length === 0 ? (
|
{stories.length === 0 ? (
|
||||||
<div className="text-center py-20">
|
<div className="opacity-0 animate-fade-in-up animation-delay-100 text-center py-20">
|
||||||
<div className="inline-flex items-center justify-center w-32 h-40 mb-6 bg-white/5 border-2 border-dashed border-border rounded-sm">
|
<div className="relative mb-8">
|
||||||
<Plus className="w-16 h-16 text-muted-foreground/50" />
|
<div className="inline-flex items-center justify-center w-40 h-52 glass-panel border-2 border-dashed border-border rounded-lg hover:border-indigo/30 hover:shadow-indigo/10 hover:shadow-lg transition-all duration-300 group">
|
||||||
</div>
|
<Plus className="w-20 h-20 text-muted-foreground/50 group-hover:text-indigo/40 transition-colors duration-300" />
|
||||||
<h2 className="text-xl font-semibold mb-2">No comics yet</h2>
|
</div>
|
||||||
<p className="text-muted-foreground mb-6">
|
<div className="absolute -inset-1 bg-gradient-to-r from-indigo/20 to-emerald/20 rounded-lg blur opacity-0 group-hover:opacity-100 transition-opacity duration-300 -z-10" />
|
||||||
Create your first comic story to build your library!
|
</div>
|
||||||
</p>
|
<h2 className="text-4xl font-heading font-semibold mb-4 text-foreground tracking-tight">
|
||||||
</div>
|
Your Comic Library Awaits
|
||||||
) : (
|
</h2>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
<p className="text-muted-foreground mb-10 max-w-lg mx-auto leading-relaxed text-lg">
|
||||||
{stories.map((story) => (
|
Start crafting your first visual story. Every great comic begins with a single panel.
|
||||||
<button
|
</p>
|
||||||
key={story.id}
|
<Button
|
||||||
onClick={() => router.push(`/editor/${story.slug}`)}
|
onClick={() => router.push('/')}
|
||||||
className="group relative bg-white aspect-[3/4] p-2 shadow-2xl rounded-sm hover:shadow-indigo/20 hover:shadow-3xl transition-all duration-300 hover:scale-[1.02] hover:-translate-y-1"
|
className="glass-panel glass-panel-hover px-8 py-4 text-base font-medium hover:scale-105 transition-all duration-300 hover:shadow-indigo/20 hover:shadow-lg"
|
||||||
>
|
>
|
||||||
<div className="w-full h-full bg-neutral-900 border-4 border-black overflow-hidden relative">
|
<Plus className="w-5 h-5 mr-3" />
|
||||||
{story.coverImage ? (
|
Create Your First Comic
|
||||||
<>
|
</Button>
|
||||||
<img
|
</div>
|
||||||
src={story.coverImage}
|
) : (
|
||||||
alt={story.title}
|
<>
|
||||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105 opacity-80"
|
<div className="opacity-0 animate-fade-in-up animation-delay-100 mb-12">
|
||||||
/>
|
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-heading font-semibold text-foreground mb-4 tracking-tight">
|
||||||
|
Your Comic Library
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-base sm:text-lg max-w-3xl leading-relaxed">
|
||||||
|
Browse and continue your comic creations. Each story is a unique visual narrative waiting to unfold.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{story.pageCount > 1 && (
|
<div className="opacity-0 animate-fade-in-up animation-delay-200 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4 sm:gap-6">
|
||||||
<div className="absolute inset-0 pointer-events-none">
|
{stories.map((story, index) => (
|
||||||
<div className="absolute top-0 left-0 right-0 bottom-0 translate-x-1 translate-y-1 bg-black/20" />
|
<button
|
||||||
{story.pageCount > 2 && (
|
key={story.id}
|
||||||
<div className="absolute top-0 left-0 right-0 bottom-0 translate-x-2 translate-y-2 bg-black/10" />
|
onClick={() => router.push(`/editor/${story.slug}`)}
|
||||||
)}
|
className={`opacity-0 animate-fade-in-up group relative glass-panel p-3 rounded-lg hover:shadow-indigo/20 hover:shadow-2xl transition-all duration-500 hover:scale-[1.05] hover:-translate-y-2 border border-border/50 hover:border-indigo/30 hover:bg-white/[0.02] backdrop-blur-sm focus:outline-none`}
|
||||||
</div>
|
style={{ animationDelay: `${200 + index * 100}ms` }}
|
||||||
)}
|
>
|
||||||
|
<div className="w-full h-full bg-neutral-900 border-4 border-black overflow-hidden relative group-hover:border-indigo/30 transition-colors duration-300">
|
||||||
|
{story.coverImage ? (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src={story.coverImage}
|
||||||
|
alt={story.title}
|
||||||
|
className="w-full h-full object-cover transition-all duration-500 group-hover:scale-105 group-hover:brightness-110 opacity-90"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent" />
|
{story.pageCount > 1 && (
|
||||||
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
|
<div className="absolute top-0 left-0 right-0 bottom-0 translate-x-1 translate-y-1 bg-black/20 group-hover:bg-indigo/10 transition-colors duration-300" />
|
||||||
|
{story.pageCount > 2 && (
|
||||||
|
<div className="absolute top-0 left-0 right-0 bottom-0 translate-x-2 translate-y-2 bg-black/10 group-hover:bg-indigo/5 transition-colors duration-300" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="absolute top-2 right-2 px-1.5 py-0.5 bg-black/70 text-white text-[9px] font-mono uppercase tracking-widest border border-white/10">
|
<div className="absolute inset-0 bg-gradient-to-t from-black/95 via-black/30 to-transparent group-hover:from-black/90 transition-all duration-300" />
|
||||||
{story.pageCount}p
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 p-2 text-left">
|
{/* Subtle glow effect on hover */}
|
||||||
<h3 className="font-display text-xs text-white leading-tight line-clamp-2 mb-0.5">
|
<div className="absolute inset-0 opacity-0 group-hover:opacity-20 bg-gradient-to-r from-indigo/20 via-transparent to-emerald/20 transition-opacity duration-500" />
|
||||||
{story.title}
|
|
||||||
</h3>
|
<div className="absolute top-2 right-2 px-1.5 py-0.5 bg-black/80 text-white text-[9px] font-mono uppercase tracking-widest border border-white/20 group-hover:bg-indigo/80 group-hover:border-indigo/40 transition-colors duration-300">
|
||||||
<p className="text-[9px] text-white/50 font-mono uppercase tracking-wider">
|
{COMIC_STYLES.find(s => s.id === story.style)?.name.toUpperCase() || story.style.toUpperCase()}
|
||||||
{new Date(story.createdAt).toLocaleDateString()}
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
<div className="absolute bottom-0 left-0 right-0 p-3 text-left">
|
||||||
</>
|
<h3 className="font-display text-sm text-white leading-tight line-clamp-1 mb-1 group-hover:text-indigo-100 transition-colors duration-300">
|
||||||
) : (
|
{story.title}
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
</h3>
|
||||||
<div className="text-center">
|
<p className="text-[10px] text-white/60 font-mono uppercase tracking-wider group-hover:text-white/80 transition-colors duration-300">
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-white/40 mx-auto mb-2" />
|
{new Date(story.createdAt).toLocaleDateString()}
|
||||||
<p className="text-[9px] text-white/50 font-mono uppercase tracking-wider">Generating...</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)}
|
) : (
|
||||||
</div>
|
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-neutral-800 to-neutral-900">
|
||||||
</button>
|
<div className="text-center">
|
||||||
))}
|
<Loader2 className="w-8 h-8 animate-spin text-indigo/60 mx-auto mb-3" />
|
||||||
</div>
|
<p className="text-xs text-white/70 font-mono uppercase tracking-wider">Generating...</p>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
DialogDescription,
|
DialogDescription,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { TOGETHER_LINK } from "@/lib/utils";
|
import { TOGETHER_LINK } from "@/lib/utils";
|
||||||
|
import { useApiKey } from "@/hooks/use-api-key";
|
||||||
|
|
||||||
interface ApiKeyModalProps {
|
interface ApiKeyModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -21,38 +22,35 @@ interface ApiKeyModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ApiKeyModal({ isOpen, onClose, onSubmit }: ApiKeyModalProps) {
|
export function ApiKeyModal({ isOpen, onClose, onSubmit }: ApiKeyModalProps) {
|
||||||
const [apiKey, setApiKey] = useState("");
|
const [apiKeyInput, setApiKeyInput] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [existingKey, setExistingKey] = useState<string | null>(null);
|
const [existingKey, setApiKey] = useApiKey();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== "undefined" && isOpen) {
|
if (isOpen) {
|
||||||
const storedKey = localStorage.getItem("together_api_key");
|
setApiKeyInput((current) => {
|
||||||
setExistingKey(storedKey);
|
if (existingKey && current === "") {
|
||||||
setApiKey((current) => {
|
return existingKey;
|
||||||
if (storedKey && current === "") {
|
|
||||||
return storedKey;
|
|
||||||
}
|
}
|
||||||
return current;
|
return current;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen, existingKey]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!apiKey.trim()) return;
|
if (!apiKeyInput.trim()) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
onSubmit(apiKey.trim());
|
onSubmit(apiKeyInput.trim());
|
||||||
setApiKey("");
|
setApiKeyInput("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
localStorage.removeItem("together_api_key");
|
setApiKey(null);
|
||||||
setExistingKey(null);
|
setApiKeyInput("");
|
||||||
setApiKey("");
|
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -83,15 +81,15 @@ export function ApiKeyModal({ isOpen, onClose, onSubmit }: ApiKeyModalProps) {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
value={apiKey}
|
value={apiKeyInput}
|
||||||
onChange={(e) => setApiKey(e.target.value)}
|
onChange={(e) => setApiKeyInput(e.target.value)}
|
||||||
placeholder={existingKey ? "Your current API key" : "Enter your API key..."}
|
placeholder={existingKey ? "Your current API key" : "Enter your API key..."}
|
||||||
className="bg-secondary border-border/50 text-white placeholder-muted-foreground py-5 pr-10"
|
className="bg-secondary border-border/50 text-white placeholder-muted-foreground py-5 pr-10"
|
||||||
/>
|
/>
|
||||||
{apiKey && (
|
{apiKeyInput && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setApiKey("")}
|
onClick={() => setApiKeyInput("")}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-white transition-colors"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
@@ -120,7 +118,7 @@ export function ApiKeyModal({ isOpen, onClose, onSubmit }: ApiKeyModalProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!apiKey.trim() || isLoading}
|
disabled={!apiKeyInput.trim() || isLoading}
|
||||||
className="flex-1 gap-2 bg-white hover:bg-neutral-200 text-black"
|
className="flex-1 gap-2 bg-white hover:bg-neutral-200 text-black"
|
||||||
>
|
>
|
||||||
{isLoading ? "Validating..." : "Continue"}
|
{isLoading ? "Validating..." : "Continue"}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RefreshCw, Download } from "lucide-react";
|
import { RefreshCw, Share, Info, Loader2, Trash2 } from "lucide-react";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
interface PageData {
|
interface PageData {
|
||||||
@@ -8,15 +9,38 @@ interface PageData {
|
|||||||
title: string;
|
title: string;
|
||||||
image: string;
|
image: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
characterUpload?: string;
|
characterUploads?: string[];
|
||||||
style: string;
|
style: string;
|
||||||
|
dbId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ComicCanvasProps {
|
interface ComicCanvasProps {
|
||||||
page: PageData;
|
page: PageData;
|
||||||
|
pageIndex: number;
|
||||||
|
totalPages?: number;
|
||||||
|
isLoading?: boolean;
|
||||||
|
isOwner?: boolean;
|
||||||
|
onInfoClick?: () => void;
|
||||||
|
onRedrawClick?: () => void;
|
||||||
|
onDeletePage?: () => void;
|
||||||
|
onNextPage?: () => void;
|
||||||
|
onPrevPage?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ComicCanvas({ page }: ComicCanvasProps) {
|
export function ComicCanvas({
|
||||||
|
page,
|
||||||
|
pageIndex,
|
||||||
|
totalPages = 1,
|
||||||
|
isLoading = false,
|
||||||
|
isOwner = true,
|
||||||
|
onInfoClick,
|
||||||
|
onRedrawClick,
|
||||||
|
onDeletePage,
|
||||||
|
onNextPage,
|
||||||
|
onPrevPage,
|
||||||
|
}: ComicCanvasProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex-1 overflow-auto p-4 md:p-8 flex items-start justify-center relative">
|
<main className="flex-1 overflow-auto p-4 md:p-8 flex items-start justify-center relative">
|
||||||
{/* Dot grid background */}
|
{/* Dot grid background */}
|
||||||
@@ -32,7 +56,20 @@ export function ComicCanvas({ page }: ComicCanvasProps) {
|
|||||||
<img
|
<img
|
||||||
src={page.image || "/placeholder.svg"}
|
src={page.image || "/placeholder.svg"}
|
||||||
alt={`Page ${page.id}`}
|
alt={`Page ${page.id}`}
|
||||||
className="w-full h-full object-cover opacity-90 grayscale-10 contrast-110"
|
className="w-full h-full object-cover opacity-90 grayscale-10 contrast-110 cursor-pointer"
|
||||||
|
onClick={(e) => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const clickX = e.clientX - rect.left;
|
||||||
|
const imageWidth = rect.width;
|
||||||
|
|
||||||
|
if (clickX > imageWidth / 2) {
|
||||||
|
// Right half - next page
|
||||||
|
onNextPage?.();
|
||||||
|
} else {
|
||||||
|
// Left half - previous page
|
||||||
|
onPrevPage?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="scan-line opacity-30" />
|
<div className="scan-line opacity-30" />
|
||||||
@@ -44,25 +81,93 @@ export function ComicCanvas({ page }: ComicCanvasProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons below the page image */}
|
||||||
|
<div className="flex items-center justify-center gap-2 mt-4">
|
||||||
|
{onInfoClick && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="hover:bg-secondary text-muted-foreground hover:text-white h-9 w-9"
|
||||||
|
onClick={onInfoClick}
|
||||||
|
>
|
||||||
|
<Info className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOwner && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="hover:bg-secondary text-muted-foreground hover:text-white gap-2 text-xs h-9 px-3"
|
||||||
|
onClick={onRedrawClick}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
<span>{isLoading ? "Redrawing..." : "Redraw"}</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOwner && totalPages > 1 && onDeletePage && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="hover:bg-red-600/20 text-muted-foreground hover:text-red-400 gap-2 text-xs h-9 px-3"
|
||||||
|
onClick={onDeletePage}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
<span>Delete</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-3 mt-4">
|
<div className="flex flex-col items-center gap-3 mt-4">
|
||||||
{/* <div className="text-xs text-muted-foreground">Page {page.id}</div> */}
|
{/* <div className="text-xs text-muted-foreground">Page {page.id}</div> */}
|
||||||
|
|
||||||
{/* Mobile action buttons */}
|
{/* Mobile action buttons */}
|
||||||
<div className="flex items-center gap-2 md:hidden">
|
<div className="flex items-center gap-2 md:hidden">
|
||||||
<Button
|
{isOwner && (
|
||||||
variant="ghost"
|
<Button
|
||||||
className="hover:bg-secondary text-muted-foreground hover:text-white gap-2 text-xs h-9 px-3 flex-1"
|
variant="ghost"
|
||||||
>
|
className="hover:bg-secondary text-muted-foreground hover:text-white gap-2 text-xs h-9 px-3 flex-1"
|
||||||
<RefreshCw className="w-4 h-4" />
|
onClick={onRedrawClick}
|
||||||
<span>Redraw</span>
|
disabled={isLoading}
|
||||||
</Button>
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
<span>{isLoading ? "Redrawing..." : "Redraw"}</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="hover:bg-secondary text-muted-foreground hover:text-white gap-2 text-xs h-9 px-3 flex-1"
|
className="hover:bg-secondary text-muted-foreground hover:text-white gap-2 text-xs h-9 px-3 flex-1"
|
||||||
|
onClick={async () => {
|
||||||
|
const url = window.location.href;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
toast({
|
||||||
|
title: "Link copied!",
|
||||||
|
description: "Story URL has been copied to your clipboard.",
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to copy URL:", err);
|
||||||
|
toast({
|
||||||
|
title: "Failed to copy",
|
||||||
|
description: "Could not copy the URL to clipboard.",
|
||||||
|
variant: "destructive",
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Download className="w-4 h-4" />
|
<Share className="w-4 h-4" />
|
||||||
<span>Download</span>
|
<span>Share</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { ArrowLeft, RefreshCw, Download, Plus, Info } from "lucide-react"
|
import { ArrowLeft, RefreshCw, Share, Plus, Info } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
interface EditorToolbarProps {
|
interface EditorToolbarProps {
|
||||||
title: string
|
title: string;
|
||||||
onContinueStory: () => void
|
onContinueStory?: () => void;
|
||||||
onInfoClick: () => void
|
isOwner?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditorToolbar({ title, onContinueStory, onInfoClick }: EditorToolbarProps) {
|
export function EditorToolbar({
|
||||||
const router = useRouter()
|
title,
|
||||||
|
onContinueStory,
|
||||||
|
isOwner = true,
|
||||||
|
}: EditorToolbarProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="h-14 border-b border-border/50 bg-background/80 backdrop-blur-md flex items-center justify-between px-3 sm:px-4">
|
<header className="h-14 border-b border-border/50 bg-background/80 backdrop-blur-md flex items-center justify-between px-3 sm:px-4">
|
||||||
@@ -19,50 +25,56 @@ export function EditorToolbar({ title, onContinueStory, onInfoClick }: EditorToo
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => router.push("/stories")}
|
onClick={() => (isOwner ? router.push("/stories") : router.push("/"))}
|
||||||
className="hover:bg-secondary text-muted-foreground hover:text-white flex-shrink-0"
|
className="hover:bg-secondary text-muted-foreground hover:text-white shrink-0"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4 sm:w-5 sm:h-5" />
|
<ArrowLeft className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<h1 className="text-sm sm:text-base text-white font-normal tracking-[-0.02em] truncate">{title}</h1>
|
<h1 className="text-sm sm:text-base text-white font-normal tracking-[-0.02em] truncate">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1 sm:gap-2 flex-shrink-0">
|
<div className="flex items-center gap-1 sm:gap-2 shrink-0">
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="hover:bg-secondary text-muted-foreground hover:text-white h-8 w-8 sm:h-9 sm:w-9"
|
|
||||||
onClick={onInfoClick}
|
|
||||||
>
|
|
||||||
<Info className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="hover:bg-secondary text-muted-foreground hover:text-white gap-1.5 sm:gap-2 text-xs h-8 sm:h-9 px-2 sm:px-3 hidden md:flex"
|
className="hover:bg-secondary text-muted-foreground hover:text-white gap-1.5 sm:gap-2 text-xs h-8 sm:h-9 px-2 sm:px-3 hidden md:flex"
|
||||||
|
onClick={async () => {
|
||||||
|
const url = window.location.href;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
toast({
|
||||||
|
title: "Link copied!",
|
||||||
|
description: "Story URL has been copied to your clipboard.",
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to copy URL:", err);
|
||||||
|
toast({
|
||||||
|
title: "Failed to copy",
|
||||||
|
description: "Could not copy the URL to clipboard.",
|
||||||
|
variant: "destructive",
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
<Share className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
||||||
<span>Redraw</span>
|
<span>Share</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
{isOwner && onContinueStory && (
|
||||||
variant="ghost"
|
<Button
|
||||||
className="hover:bg-secondary text-muted-foreground hover:text-white gap-1.5 sm:gap-2 text-xs h-8 sm:h-9 px-2 sm:px-3 hidden md:flex"
|
onClick={onContinueStory}
|
||||||
>
|
className="gap-1.5 sm:gap-2 text-xs bg-white hover:bg-neutral-200 text-black h-8 sm:h-9 px-3 sm:px-4"
|
||||||
<Download className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
>
|
||||||
<span>Download PDF</span>
|
<Plus className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
||||||
</Button>
|
<span className="hidden sm:inline">Continue story</span>
|
||||||
|
<span className="sm:hidden">Add</span>
|
||||||
<Button
|
</Button>
|
||||||
onClick={onContinueStory}
|
)}
|
||||||
className="gap-1.5 sm:gap-2 text-xs bg-white hover:bg-neutral-200 text-black h-8 sm:h-9 px-3 sm:px-4"
|
|
||||||
>
|
|
||||||
<Plus className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
|
||||||
<span className="hidden sm:inline">Continue story</span>
|
|
||||||
<span className="sm:hidden">Add</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,40 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useMemo } from "react"
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { Upload, X, Loader2 } from "lucide-react"
|
import { Upload, X, Loader2, Check } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import {
|
||||||
import { useToast } from "@/hooks/use-toast"
|
Dialog,
|
||||||
import { validateFileForUpload, generateFilePreview } from "@/lib/file-utils"
|
DialogContent,
|
||||||
import { COMIC_STYLES } from "@/lib/constants"
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogClose,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut";
|
||||||
|
import { validateFileForUpload, generateFilePreview } from "@/lib/file-utils";
|
||||||
|
import { useS3Upload } from "next-s3-upload";
|
||||||
|
|
||||||
|
interface CharacterItem {
|
||||||
|
url: string;
|
||||||
|
isNew?: boolean;
|
||||||
|
file?: File;
|
||||||
|
preview?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface GeneratePageModalProps {
|
interface GeneratePageModalProps {
|
||||||
isOpen: boolean
|
isOpen: boolean;
|
||||||
onClose: () => void
|
onClose: () => void;
|
||||||
onGenerate: (data: {
|
onGenerate: (data: {
|
||||||
prompt: string
|
prompt: string;
|
||||||
style: string
|
characterUrls?: string[];
|
||||||
characterFiles?: File[]
|
}) => Promise<void>;
|
||||||
characterUrls?: string[] // For reusing existing characters
|
pageNumber: number;
|
||||||
isContinuation?: boolean
|
isRedrawMode?: boolean;
|
||||||
}) => void
|
existingPrompt?: string;
|
||||||
pageNumber: number
|
existingCharacters?: string[]; // All characters from the story
|
||||||
previousCharacters?: File[]
|
lastPageCharacters?: string[]; // Characters used on the last page
|
||||||
previousPagePrompt?: string
|
previousPageCharacters?: string[]; // Characters used on the previous page (if last page had < 2)
|
||||||
previousPageStyle?: string
|
|
||||||
existingCharacterImages?: string[] // Character images from previous pages
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GeneratePageModal({
|
export function GeneratePageModal({
|
||||||
@@ -30,55 +42,103 @@ export function GeneratePageModal({
|
|||||||
onClose,
|
onClose,
|
||||||
onGenerate,
|
onGenerate,
|
||||||
pageNumber,
|
pageNumber,
|
||||||
previousCharacters,
|
isRedrawMode = false,
|
||||||
previousPagePrompt,
|
existingPrompt = "",
|
||||||
previousPageStyle,
|
existingCharacters = [],
|
||||||
existingCharacterImages = [],
|
lastPageCharacters = [],
|
||||||
|
previousPageCharacters = [],
|
||||||
}: GeneratePageModalProps) {
|
}: GeneratePageModalProps) {
|
||||||
const [prompt, setPrompt] = useState("")
|
const [prompt, setPrompt] = useState("");
|
||||||
const [uploadedFiles, setUploadedFiles] = useState<File[]>(previousCharacters || [])
|
const [characters, setCharacters] = useState<CharacterItem[]>([]);
|
||||||
const [selectedExistingCharacters, setSelectedExistingCharacters] = useState<string[]>([])
|
const [selectedCharacterIndices, setSelectedCharacterIndices] = useState<
|
||||||
const [previews, setPreviews] = useState<string[]>([])
|
Set<number>
|
||||||
const [showPreview, setShowPreview] = useState<number | null>(null)
|
>(new Set());
|
||||||
const [isGenerating, setIsGenerating] = useState(false)
|
const [showPreview, setShowPreview] = useState<string | null>(null);
|
||||||
const [isContinuing, setIsContinuing] = useState(false)
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { toast } = useToast()
|
const { toast } = useToast();
|
||||||
|
const { uploadToS3 } = useS3Upload();
|
||||||
const selectedStyleId = previousPageStyle || "noir"
|
|
||||||
const selectedStyle = useMemo(
|
|
||||||
() => COMIC_STYLES.find((s) => s.id === selectedStyleId)?.name || "Noir",
|
|
||||||
[selectedStyleId]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
// Reset form and initialize characters when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (previousCharacters && previousCharacters.length > 0) {
|
if (isOpen) {
|
||||||
const newPreviews: string[] = []
|
setPrompt(isRedrawMode ? existingPrompt : "");
|
||||||
previousCharacters.forEach((file, index) => {
|
setShowPreview(null);
|
||||||
const reader = new FileReader()
|
setIsGenerating(false);
|
||||||
reader.onload = (e) => {
|
|
||||||
newPreviews[index] = e.target?.result as string
|
// Initialize characters list with existing ones
|
||||||
if (newPreviews.filter(Boolean).length === previousCharacters.length) {
|
const existingItems: CharacterItem[] = existingCharacters.map((url) => ({
|
||||||
setPreviews([...newPreviews])
|
url,
|
||||||
|
isNew: false,
|
||||||
|
}));
|
||||||
|
setCharacters(existingItems);
|
||||||
|
|
||||||
|
// Smart selection: Use last 2 characters from last page, or combine with previous page if needed
|
||||||
|
const defaultSelected = new Set<number>();
|
||||||
|
const charactersToSelect: string[] = [];
|
||||||
|
|
||||||
|
// If last page has 2 characters, use those
|
||||||
|
if (lastPageCharacters.length >= 2) {
|
||||||
|
charactersToSelect.push(...lastPageCharacters.slice(0, 2));
|
||||||
|
} else {
|
||||||
|
// Start with last page characters (if any)
|
||||||
|
charactersToSelect.push(...lastPageCharacters);
|
||||||
|
|
||||||
|
// If we have less than 2, add from previous page (avoiding duplicates)
|
||||||
|
if (
|
||||||
|
charactersToSelect.length < 2 &&
|
||||||
|
previousPageCharacters.length > 0
|
||||||
|
) {
|
||||||
|
for (const charUrl of previousPageCharacters) {
|
||||||
|
if (
|
||||||
|
!charactersToSelect.includes(charUrl) &&
|
||||||
|
charactersToSelect.length < 2
|
||||||
|
) {
|
||||||
|
charactersToSelect.push(charUrl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
reader.readAsDataURL(file)
|
}
|
||||||
})
|
|
||||||
|
// Find indices of characters to select (preserving order in existingItems)
|
||||||
|
charactersToSelect.forEach((charUrl) => {
|
||||||
|
const index = existingItems.findIndex((item) => item.url === charUrl);
|
||||||
|
if (index !== -1) {
|
||||||
|
defaultSelected.add(index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setSelectedCharacterIndices(defaultSelected);
|
||||||
}
|
}
|
||||||
}, [previousCharacters])
|
}, [
|
||||||
|
isOpen,
|
||||||
|
isRedrawMode,
|
||||||
|
existingPrompt,
|
||||||
|
existingCharacters,
|
||||||
|
lastPageCharacters,
|
||||||
|
previousPageCharacters,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Keyboard shortcut for form submission (disabled during generation)
|
||||||
|
useKeyboardShortcut(
|
||||||
|
() => {
|
||||||
|
if (isOpen && !isGenerating && prompt.trim()) {
|
||||||
|
handleGenerate();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ disabled: !isOpen || isGenerating }
|
||||||
|
);
|
||||||
|
|
||||||
const handleFiles = async (newFiles: FileList | null) => {
|
const handleFiles = async (newFiles: FileList | null) => {
|
||||||
if (!newFiles) return
|
if (!newFiles) return;
|
||||||
|
|
||||||
const filesArray = Array.from(newFiles)
|
const filesArray = Array.from(newFiles);
|
||||||
|
|
||||||
// Validate files (including WebP rejection)
|
const validationResults = filesArray.map((file) => ({
|
||||||
const validationResults = filesArray.map(file => ({
|
|
||||||
file,
|
file,
|
||||||
validation: validateFileForUpload(file, true)
|
validation: validateFileForUpload(file, true),
|
||||||
}))
|
}));
|
||||||
|
|
||||||
// Show errors for invalid files
|
|
||||||
validationResults.forEach(({ validation }) => {
|
validationResults.forEach(({ validation }) => {
|
||||||
if (!validation.valid && validation.error) {
|
if (!validation.valid && validation.error) {
|
||||||
toast({
|
toast({
|
||||||
@@ -86,255 +146,326 @@ export function GeneratePageModal({
|
|||||||
description: validation.error,
|
description: validation.error,
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
duration: 4000,
|
duration: 4000,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
const validFiles = validationResults
|
const validFiles = validationResults
|
||||||
.filter(({ validation }) => validation.valid)
|
.filter(({ validation }) => validation.valid)
|
||||||
.map(({ file }) => file)
|
.map(({ file }) => file);
|
||||||
|
|
||||||
if (validFiles.length === 0) return
|
if (validFiles.length === 0) return;
|
||||||
|
|
||||||
const totalFiles = [...uploadedFiles, ...validFiles].slice(0, 2) // Max 2 files
|
// Create new character items for the uploaded files
|
||||||
setUploadedFiles(totalFiles)
|
const newCharacterItems: CharacterItem[] = await Promise.all(
|
||||||
|
validFiles.map(async (file) => {
|
||||||
|
const preview = await generateFilePreview(file);
|
||||||
|
return {
|
||||||
|
url: "", // Will be set after S3 upload
|
||||||
|
isNew: true,
|
||||||
|
file,
|
||||||
|
preview,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// Generate previews for all files
|
// Add new characters to the list
|
||||||
const newPreviews = await Promise.all(
|
setCharacters((prev) => {
|
||||||
totalFiles.map((file) => generateFilePreview(file))
|
const updated = [...prev, ...newCharacterItems];
|
||||||
)
|
const newSelected = new Set(selectedCharacterIndices);
|
||||||
setPreviews(newPreviews)
|
|
||||||
}
|
// Add new characters to selection
|
||||||
|
newCharacterItems.forEach((_, idx) => {
|
||||||
|
newSelected.add(prev.length + idx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// If we have more than 2 selected, deselect the oldest ones (keep most recent 2)
|
||||||
|
if (newSelected.size > 2) {
|
||||||
|
const selectedArray = Array.from(newSelected).sort((a, b) => b - a);
|
||||||
|
const toKeep = selectedArray.slice(0, 2);
|
||||||
|
newSelected.clear();
|
||||||
|
toKeep.forEach((idx) => newSelected.add(idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedCharacterIndices(newSelected);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
const removeFile = (index: number) => {
|
|
||||||
const newFiles = uploadedFiles.filter((_, i) => i !== index)
|
|
||||||
const newPreviews = previews.filter((_, i) => i !== index)
|
|
||||||
setUploadedFiles(newFiles)
|
|
||||||
setPreviews(newPreviews)
|
|
||||||
setShowPreview(null)
|
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = ""
|
fileInputRef.current.value = "";
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const toggleExistingCharacter = (characterUrl: string) => {
|
const toggleCharacterSelection = (index: number) => {
|
||||||
setSelectedExistingCharacters(prev =>
|
setSelectedCharacterIndices((prev) => {
|
||||||
prev.includes(characterUrl)
|
const newSelected = new Set(prev);
|
||||||
? prev.filter(url => url !== characterUrl)
|
if (newSelected.has(index)) {
|
||||||
: [...prev, characterUrl]
|
// Allow deselection even if only 2 are selected
|
||||||
)
|
newSelected.delete(index);
|
||||||
}
|
} else {
|
||||||
|
// If already at max (2), remove the oldest selected first
|
||||||
|
if (newSelected.size >= 2) {
|
||||||
|
const selectedArray = Array.from(newSelected).sort((a, b) => a - b);
|
||||||
|
newSelected.delete(selectedArray[0]); // Remove oldest
|
||||||
|
}
|
||||||
|
newSelected.add(index);
|
||||||
|
}
|
||||||
|
return newSelected;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleGenerate = () => {
|
const removeCharacter = (index: number) => {
|
||||||
if (!prompt.trim()) return
|
setCharacters((prev) => {
|
||||||
setIsGenerating(true)
|
const updated = prev.filter((_, i) => i !== index);
|
||||||
onGenerate({
|
|
||||||
prompt,
|
|
||||||
style: selectedStyle,
|
|
||||||
characterFiles: uploadedFiles.length > 0 ? uploadedFiles : undefined,
|
|
||||||
characterUrls: selectedExistingCharacters.length > 0 ? selectedExistingCharacters : undefined,
|
|
||||||
isContinuation: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleContinue = () => {
|
// Adjust selected indices
|
||||||
setIsContinuing(true)
|
setSelectedCharacterIndices((prevSelected) => {
|
||||||
onGenerate({
|
const newSelected = new Set<number>();
|
||||||
prompt: prompt.trim() || `Continue the story from where it left off. Previous context: ${previousPagePrompt}`,
|
prevSelected.forEach((idx) => {
|
||||||
style: selectedStyle,
|
if (idx < index) {
|
||||||
characterFiles: uploadedFiles.length > 0 ? uploadedFiles : undefined,
|
newSelected.add(idx);
|
||||||
characterUrls: selectedExistingCharacters.length > 0 ? selectedExistingCharacters : undefined,
|
} else if (idx > index) {
|
||||||
isContinuation: true,
|
newSelected.add(idx - 1);
|
||||||
})
|
}
|
||||||
}
|
// Skip the removed index
|
||||||
|
});
|
||||||
|
return newSelected;
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
return updated;
|
||||||
if (!isOpen) {
|
});
|
||||||
setIsGenerating(false)
|
setShowPreview(null);
|
||||||
setIsContinuing(false)
|
};
|
||||||
setPrompt("")
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
if (!prompt.trim()) return;
|
||||||
|
setIsGenerating(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get selected characters
|
||||||
|
const selectedCharacters = Array.from(selectedCharacterIndices)
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.map((idx) => characters[idx])
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
// Upload new files to S3 and get URLs, reuse existing URLs
|
||||||
|
const characterUrls = await Promise.all(
|
||||||
|
selectedCharacters.map(async (char) => {
|
||||||
|
if (char.isNew && char.file) {
|
||||||
|
// Upload new file to S3
|
||||||
|
const { url } = await uploadToS3(char.file);
|
||||||
|
return url;
|
||||||
|
} else {
|
||||||
|
// Reuse existing URL
|
||||||
|
return char.url;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await onGenerate({
|
||||||
|
prompt,
|
||||||
|
characterUrls: characterUrls.length > 0 ? characterUrls : undefined,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating page:", error);
|
||||||
|
toast({
|
||||||
|
title: "Generation failed",
|
||||||
|
description:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to generate page. Please try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
duration: 4000,
|
||||||
|
});
|
||||||
|
setIsGenerating(false);
|
||||||
}
|
}
|
||||||
}, [isOpen])
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (open: boolean) => {
|
||||||
|
// Prevent closing the modal if generation is running
|
||||||
|
if (!open && isGenerating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent className="border border-border/50 rounded-lg bg-background max-w-lg">
|
<DialogContent className="border border-border/50 rounded-lg bg-background max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-xl text-white font-heading">Generate Page {pageNumber}</DialogTitle>
|
<DialogTitle className="text-xl text-white font-heading">
|
||||||
|
{isRedrawMode
|
||||||
|
? `Redraw Page ${pageNumber}`
|
||||||
|
: `Generate Page ${pageNumber}`}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogClose
|
||||||
|
disabled={isGenerating}
|
||||||
|
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogClose>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 mt-4">
|
<div className="space-y-4 mt-4">
|
||||||
|
{/* Prompt Input */}
|
||||||
<div className="relative glass-panel p-1 rounded-xl group focus-within:border-indigo/30 transition-colors">
|
<div className="relative glass-panel p-1 rounded-xl group focus-within:border-indigo/30 transition-colors">
|
||||||
<div className="bg-background/80 rounded-lg p-4 border border-border/50">
|
<div className="bg-background/80 rounded-lg p-4 border border-border/50">
|
||||||
<div className="flex justify-between items-center mb-3">
|
<div className="flex justify-between items-center mb-3">
|
||||||
<label className="text-[10px] uppercase text-muted-foreground tracking-[0.02em] font-medium">
|
<label className="text-[10px] uppercase text-muted-foreground tracking-[0.02em] font-medium">
|
||||||
Prompt
|
Prompt
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
|
||||||
<span className="capitalize">{selectedStyle}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
placeholder="Continue the story... Describe what happens next in the comic."
|
placeholder={
|
||||||
|
isRedrawMode
|
||||||
|
? "Tweak the prompt to improve this page..."
|
||||||
|
: "Continue the story... Describe what happens next."
|
||||||
|
}
|
||||||
|
disabled={isGenerating}
|
||||||
className="w-full bg-transparent border-none text-sm text-white placeholder-muted-foreground/50 focus:ring-0 focus:outline-none resize-none h-20 leading-relaxed tracking-tight"
|
className="w-full bg-transparent border-none text-sm text-white placeholder-muted-foreground/50 focus:ring-0 focus:outline-none resize-none h-20 leading-relaxed tracking-tight"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-3 pt-3 border-t border-border/30 space-y-3">
|
<div className="mt-3 pt-3 border-t border-border/30 space-y-3">
|
||||||
{/* Existing Characters */}
|
{/* Character Selection */}
|
||||||
{existingCharacterImages.length > 0 && (
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
<label className="text-[10px] uppercase text-muted-foreground tracking-[0.02em] font-medium">
|
||||||
<div className="text-xs text-muted-foreground uppercase tracking-[0.02em] font-medium">
|
Characters (select up to 2)
|
||||||
Reuse Characters from Story
|
</label>
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
{existingCharacterImages.map((characterUrl, index) => {
|
|
||||||
const isSelected = selectedExistingCharacters.includes(characterUrl)
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={characterUrl}
|
|
||||||
onClick={() => toggleExistingCharacter(characterUrl)}
|
|
||||||
className={`relative w-8 h-8 rounded-md overflow-hidden border-2 transition-all ${
|
|
||||||
isSelected
|
|
||||||
? "border-indigo shadow-sm shadow-indigo/20"
|
|
||||||
: "border-border/50 hover:border-indigo/50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={characterUrl}
|
|
||||||
alt={`Existing character ${index + 1}`}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
{isSelected && (
|
|
||||||
<div className="absolute inset-0 bg-indigo/20 flex items-center justify-center">
|
|
||||||
<div className="w-3 h-3 bg-indigo rounded-full flex items-center justify-center">
|
|
||||||
<div className="w-1 h-1 bg-white rounded-full" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* New Character Uploads */}
|
{/* Existing and new characters list */}
|
||||||
<div className="flex items-center justify-between gap-2">
|
{characters.length > 0 && (
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
<div className="flex flex-wrap gap-2">
|
||||||
{uploadedFiles.length > 0 ? (
|
{characters.map((char, index) => {
|
||||||
<div className="flex items-center gap-2">
|
const isSelected =
|
||||||
{previews.map((preview, index) => (
|
selectedCharacterIndices.has(index);
|
||||||
|
const imageUrl = char.preview || char.url;
|
||||||
|
|
||||||
|
return (
|
||||||
<div key={index} className="relative group/thumb">
|
<div key={index} className="relative group/thumb">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowPreview(index)}
|
type="button"
|
||||||
className="w-8 h-8 rounded-md overflow-hidden border border-border/50 hover:border-indigo/50 transition-colors"
|
onClick={() => toggleCharacterSelection(index)}
|
||||||
|
onDoubleClick={() => setShowPreview(imageUrl)}
|
||||||
|
disabled={isGenerating}
|
||||||
|
className={`w-10 h-10 rounded-md overflow-hidden transition-all disabled:opacity-50 disabled:cursor-not-allowed relative ${
|
||||||
|
isSelected
|
||||||
|
? "border-2 border-indigo-500"
|
||||||
|
: "border-2 border-transparent hover:border-indigo/50"
|
||||||
|
}`}
|
||||||
|
title="Click to select/deselect, double-click to preview"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={preview || "/placeholder.svg"}
|
src={imageUrl || "/placeholder.svg"}
|
||||||
alt={`New character ${index + 1}`}
|
alt={`Character ${index + 1}`}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
{/* Selection indicator */}
|
||||||
|
{isSelected && (
|
||||||
|
<div className="absolute top-0 right-0 w-3.5 h-3.5 bg-indigo-500 rounded-full flex items-center justify-center pointer-events-none z-10 border border-background">
|
||||||
|
<Check className="w-2 h-2 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
removeFile(index)
|
|
||||||
}}
|
|
||||||
className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center opacity-0 group-hover/thumb:opacity-100 transition-opacity"
|
|
||||||
>
|
|
||||||
<X className="w-2.5 h-2.5 text-white" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{uploadedFiles.length < 2 && (
|
|
||||||
<button
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
className="w-8 h-8 rounded-md border border-dashed border-border/50 hover:border-indigo/50 flex items-center justify-center text-muted-foreground hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<Upload className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<Upload className="w-3.5 h-3.5" />
|
|
||||||
<span>Upload New Characters</span>
|
|
||||||
<span className="text-muted-foreground/50">(Max 2)</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
{/* Remove button (only for new uploads) */}
|
||||||
ref={fileInputRef}
|
{char.isNew && (
|
||||||
type="file"
|
<button
|
||||||
accept="image/png,image/jpeg,image/jpg"
|
type="button"
|
||||||
multiple
|
onClick={(e) => {
|
||||||
className="hidden"
|
e.stopPropagation();
|
||||||
onChange={(e) => handleFiles(e.target.files)}
|
removeCharacter(index);
|
||||||
/>
|
}}
|
||||||
|
disabled={isGenerating}
|
||||||
|
className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center opacity-0 group-hover/thumb:opacity-100 transition-opacity disabled:opacity-50 z-20"
|
||||||
|
>
|
||||||
|
<X className="w-2.5 h-2.5 text-white" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Upload new character button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
!isGenerating && fileInputRef.current?.click()
|
||||||
|
}
|
||||||
|
disabled={isGenerating}
|
||||||
|
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Upload className="w-3.5 h-3.5" />
|
||||||
|
<span>
|
||||||
|
{characters.length === 0
|
||||||
|
? "Add characters (optional, max 2)"
|
||||||
|
: "Upload new character"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/jpg"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => handleFiles(e.target.files)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="text-xs text-muted-foreground/70">
|
||||||
<Button
|
{isRedrawMode
|
||||||
onClick={handleContinue}
|
? "Previous pages and characters automatically referenced."
|
||||||
disabled={isGenerating || isContinuing}
|
: "Previous page automatically referenced. " +
|
||||||
variant="outline"
|
`${selectedCharacterIndices.size} selected characters.`}
|
||||||
className="flex-1 gap-2 border-indigo/30 text-indigo hover:bg-indigo/10 hover:text-indigo tracking-tight bg-transparent"
|
|
||||||
>
|
|
||||||
{isContinuing ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
<span>Continuing...</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
`Continue from Page ${pageNumber - 1}`
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleGenerate}
|
|
||||||
disabled={!prompt.trim() || isGenerating || isContinuing}
|
|
||||||
className="flex-1 gap-2 bg-white hover:bg-neutral-200 text-black tracking-tight"
|
|
||||||
>
|
|
||||||
{isGenerating ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
<span>Generating...</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
`Generate Page ${pageNumber}`
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={!prompt.trim() || isGenerating}
|
||||||
|
className="w-full gap-2 bg-white hover:bg-neutral-200 text-black tracking-tight"
|
||||||
|
>
|
||||||
|
{isGenerating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
<span>
|
||||||
|
{isRedrawMode ? "Redrawing page..." : "Generating page..."}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
`${isRedrawMode ? "Redraw" : "Generate"} Page ${pageNumber}`
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{showPreview !== null && previews[showPreview] && (
|
{/* Character Preview Modal */}
|
||||||
|
{showPreview && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-[100] flex items-center justify-center p-4"
|
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-100 flex items-center justify-center p-4"
|
||||||
onClick={() => setShowPreview(null)}
|
onClick={() => setShowPreview(null)}
|
||||||
>
|
>
|
||||||
<div className="relative max-w-2xl max-h-[80vh] glass-panel p-4 rounded-xl z-[101]">
|
<div className="relative max-w-sm max-h-[80vh] glass-panel p-4 rounded-xl z-101">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="absolute top-2 right-2 h-8 w-8 hover:bg-white/10 z-[102]"
|
className="absolute top-2 right-2 h-8 w-8 hover:bg-white/10 z-102"
|
||||||
onClick={() => setShowPreview(null)}
|
onClick={() => setShowPreview(null)}
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<img
|
<img
|
||||||
src={previews[showPreview] || "/placeholder.svg"}
|
src={showPreview || "/placeholder.svg"}
|
||||||
alt="Character preview"
|
alt="Character preview"
|
||||||
className="w-full h-full object-contain rounded-lg"
|
className="w-full h-full object-contain rounded-lg"
|
||||||
/>
|
/>
|
||||||
@@ -342,5 +473,5 @@ export function GeneratePageModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,26 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { Plus, Loader2, Key } from "lucide-react"
|
import { Plus, Loader2, Key, Trash2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
import { UserButton } from "@clerk/nextjs"
|
import { UserButton, SignedIn } from "@clerk/nextjs";
|
||||||
|
|
||||||
interface PageData {
|
interface PageData {
|
||||||
id: number
|
id: number;
|
||||||
title: string
|
title: string;
|
||||||
image: string
|
image: string;
|
||||||
prompt: string
|
prompt: string;
|
||||||
characterUpload?: string
|
characterUpload?: string;
|
||||||
style: string
|
style: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PageSidebarProps {
|
interface PageSidebarProps {
|
||||||
pages: PageData[]
|
pages: PageData[];
|
||||||
currentPage: number
|
currentPage: number;
|
||||||
onPageSelect: (index: number) => void
|
onPageSelect: (index: number) => void;
|
||||||
onAddPage: () => void
|
onAddPage: () => void;
|
||||||
loadingPageId?: number | null
|
loadingPageId?: number | null;
|
||||||
onApiKeyClick?: () => void
|
onApiKeyClick?: () => void;
|
||||||
|
isOwner?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageSidebar({
|
export function PageSidebar({
|
||||||
@@ -29,39 +30,63 @@ export function PageSidebar({
|
|||||||
onAddPage,
|
onAddPage,
|
||||||
loadingPageId,
|
loadingPageId,
|
||||||
onApiKeyClick,
|
onApiKeyClick,
|
||||||
|
isOwner = true,
|
||||||
}: PageSidebarProps) {
|
}: PageSidebarProps) {
|
||||||
return (
|
return (
|
||||||
<aside className="w-16 md:w-20 border-r border-border/50 bg-background/50 flex flex-col items-center py-4 gap-2 justify-between">
|
<aside className="w-20 md:w-24 border-r border-border/50 bg-background/50 flex flex-col items-center py-4 gap-2 justify-between">
|
||||||
{/* Top section: page numbers */}
|
{/* Top section: page thumbnails */}
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-3">
|
||||||
{pages.map((page, index) => (
|
{pages.map((page, index) => (
|
||||||
<button
|
<button
|
||||||
key={page.id}
|
key={page.id}
|
||||||
onClick={() => onPageSelect(index)}
|
onClick={() => onPageSelect(index)}
|
||||||
disabled={loadingPageId === page.id}
|
disabled={loadingPageId === index}
|
||||||
className={`
|
className={`
|
||||||
w-9 h-9 rounded-md transition-all
|
w-16 h-16 rounded-lg transition-all relative overflow-hidden
|
||||||
flex items-center justify-center font-medium text-sm tracking-tight
|
|
||||||
${
|
${
|
||||||
currentPage === index
|
currentPage === index
|
||||||
? "bg-black border-2 border-indigo text-white shadow-lg shadow-indigo/20"
|
? "ring-2 ring-indigo shadow-lg shadow-indigo/20"
|
||||||
: "glass-panel glass-panel-hover text-muted-foreground hover:text-white"
|
: "glass-panel glass-panel-hover hover:ring-1 hover:ring-white/20"
|
||||||
}
|
}
|
||||||
${loadingPageId === page.id ? "opacity-50" : ""}
|
${loadingPageId === index ? "opacity-50" : ""}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{loadingPageId === page.id ? <Loader2 className="w-4 h-4 animate-spin" /> : index + 1}
|
{loadingPageId === index ? (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-white" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src={page.image || "/placeholder.svg"}
|
||||||
|
alt={`Page ${index + 1}`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
absolute bottom-1 left-1 px-1.5 py-0.5 rounded text-[10px] font-medium tracking-tight
|
||||||
|
${
|
||||||
|
currentPage === index
|
||||||
|
? "bg-indigo text-white"
|
||||||
|
: "bg-black/70 text-white"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Button
|
{isOwner && (
|
||||||
onClick={onAddPage}
|
<button
|
||||||
variant="ghost"
|
onClick={onAddPage}
|
||||||
size="icon"
|
className="w-16 h-16 rounded-lg border-2 border-dashed border-border/50 hover:border-indigo/50 bg-background/50 hover:bg-background/80 transition-all group flex items-center justify-center"
|
||||||
className="w-9 h-9 bg-white hover:bg-neutral-200 text-black border-0 transition-all group"
|
>
|
||||||
>
|
<Plus className="w-6 h-6 text-muted-foreground group-hover:text-indigo transition-transform group-hover:scale-110" />
|
||||||
<Plus className="w-4 h-4 text-black transition-transform group-hover:scale-110" />
|
</button>
|
||||||
</Button>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
@@ -70,22 +95,24 @@ export function PageSidebar({
|
|||||||
onClick={onApiKeyClick}
|
onClick={onApiKeyClick}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="w-9 h-9 glass-panel glass-panel-hover text-muted-foreground hover:text-white"
|
className="w-10 h-10 glass-panel glass-panel-hover text-muted-foreground hover:text-white"
|
||||||
title="Manage API Key"
|
title="Manage API Key"
|
||||||
>
|
>
|
||||||
<Key className="w-4 h-4" />
|
<Key className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="w-9 h-9 glass-panel glass-panel-hover rounded-md flex items-center justify-center text-muted-foreground hover:text-white transition-colors">
|
<SignedIn>
|
||||||
<UserButton
|
<div className="w-10 h-10 glass-panel glass-panel-hover rounded-md flex items-center justify-center text-muted-foreground hover:text-white transition-colors">
|
||||||
appearance={{
|
<UserButton
|
||||||
elements: {
|
appearance={{
|
||||||
avatarBox: "w-full h-full rounded-md",
|
elements: {
|
||||||
},
|
avatarBox: "w-full h-full rounded-md",
|
||||||
}}
|
},
|
||||||
/>
|
}}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
</SignedIn>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import type React from "react"
|
|
||||||
import { useState, useRef } from "react"
|
|
||||||
import { X, Upload } from "lucide-react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { useToast } from "@/hooks/use-toast"
|
|
||||||
import { validateFileForUpload, generateFilePreview } from "@/lib/file-utils"
|
|
||||||
|
|
||||||
export function CharacterUploader() {
|
|
||||||
const [preview, setPreview] = useState<string | null>(null)
|
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
||||||
const { toast } = useToast()
|
|
||||||
|
|
||||||
const handleFile = async (file: File) => {
|
|
||||||
const validation = validateFileForUpload(file, true)
|
|
||||||
if (validation.valid) {
|
|
||||||
const previewUrl = await generateFilePreview(file)
|
|
||||||
setPreview(previewUrl)
|
|
||||||
} else if (validation.error) {
|
|
||||||
toast({
|
|
||||||
title: "Invalid file",
|
|
||||||
description: validation.error,
|
|
||||||
variant: "destructive",
|
|
||||||
duration: 4000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setIsDragging(false)
|
|
||||||
const file = e.dataTransfer.files[0]
|
|
||||||
if (file) handleFile(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setIsDragging(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragLeave = () => {
|
|
||||||
setIsDragging(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearPreview = () => {
|
|
||||||
setPreview(null)
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preview) {
|
|
||||||
return (
|
|
||||||
<div className="relative h-24 rounded-lg overflow-hidden glass-panel group transition-all">
|
|
||||||
<img src={preview || "/placeholder.svg"} alt="Character preview" className="w-full h-full object-contain p-2" />
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="absolute top-2 right-2 h-6 w-6 bg-black/50 hover:bg-black/70 opacity-70 group-hover:opacity-100 transition-opacity"
|
|
||||||
onClick={clearPreview}
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
className={`
|
|
||||||
flex items-center gap-2 px-3 py-2 rounded-md transition-all text-xs
|
|
||||||
${
|
|
||||||
isDragging
|
|
||||||
? "glass-panel border-indigo/50 text-white"
|
|
||||||
: "glass-panel glass-panel-hover text-muted-foreground hover:text-white"
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/png,image/jpeg,image/jpg"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0]
|
|
||||||
if (file) handleFile(file)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Upload className="w-3.5 h-3.5" />
|
|
||||||
<span>Upload Character</span>
|
|
||||||
<span className="text-muted-foreground/50">(Optional)</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useRef, useEffect } from "react";
|
import { Upload, X, Check, ArrowRight, Loader2 } from "lucide-react";
|
||||||
import { Upload, X, Check } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { useS3Upload } from "next-s3-upload";
|
||||||
|
import { useAuth, SignInButton } from "@clerk/nextjs";
|
||||||
import { COMIC_STYLES } from "@/lib/constants";
|
import { COMIC_STYLES } from "@/lib/constants";
|
||||||
|
import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut";
|
||||||
|
import { useApiKey } from "@/hooks/use-api-key";
|
||||||
|
|
||||||
interface StoryInputProps {
|
interface ComicCreationFormProps {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
setPrompt: (prompt: string) => void;
|
setPrompt: (prompt: string) => void;
|
||||||
style: string;
|
style: string;
|
||||||
@@ -15,9 +19,10 @@ interface StoryInputProps {
|
|||||||
characterFiles: File[];
|
characterFiles: File[];
|
||||||
setCharacterFiles: (files: File[]) => void;
|
setCharacterFiles: (files: File[]) => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
setIsLoading: (loading: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StoryInput({
|
export function ComicCreationForm({
|
||||||
prompt,
|
prompt,
|
||||||
setPrompt,
|
setPrompt,
|
||||||
style,
|
style,
|
||||||
@@ -25,11 +30,21 @@ export function StoryInput({
|
|||||||
characterFiles,
|
characterFiles,
|
||||||
setCharacterFiles,
|
setCharacterFiles,
|
||||||
isLoading,
|
isLoading,
|
||||||
}: StoryInputProps) {
|
setIsLoading,
|
||||||
|
}: ComicCreationFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loadingStep, setLoadingStep] = useState(0);
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { uploadToS3 } = useS3Upload();
|
||||||
|
const { isSignedIn, isLoaded } = useAuth();
|
||||||
|
const [apiKey] = useApiKey();
|
||||||
|
const hasApiKey = !!apiKey;
|
||||||
const [previews, setPreviews] = useState<string[]>([]);
|
const [previews, setPreviews] = useState<string[]>([]);
|
||||||
const [showPreview, setShowPreview] = useState<number | null>(null);
|
const [showPreview, setShowPreview] = useState<number | null>(null);
|
||||||
const [showStyleDropdown, setShowStyleDropdown] = useState(false);
|
const [showStyleDropdown, setShowStyleDropdown] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -37,6 +52,20 @@ export function StoryInput({
|
|||||||
}
|
}
|
||||||
}, [isLoading]);
|
}, [isLoading]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Auto-focus the textarea when component mounts
|
||||||
|
if (textareaRef.current) {
|
||||||
|
textareaRef.current.focus();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keyboard shortcut for form submission
|
||||||
|
useKeyboardShortcut(() => {
|
||||||
|
if (!isLoading && prompt.trim()) {
|
||||||
|
handleCreate();
|
||||||
|
}
|
||||||
|
}, { disabled: isLoading });
|
||||||
|
|
||||||
const handleFiles = (newFiles: FileList | null) => {
|
const handleFiles = (newFiles: FileList | null) => {
|
||||||
if (!newFiles) return;
|
if (!newFiles) return;
|
||||||
|
|
||||||
@@ -84,6 +113,93 @@ export function StoryInput({
|
|||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!prompt.trim()) {
|
||||||
|
toast({
|
||||||
|
title: "Prompt required",
|
||||||
|
description: "Please enter a prompt to generate your comic",
|
||||||
|
variant: "destructive",
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setLoadingStep(0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!apiKey) {
|
||||||
|
toast({
|
||||||
|
title: "API key required",
|
||||||
|
description: "Please add your API key to generate comics.",
|
||||||
|
variant: "destructive",
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const characterUploads = await Promise.all(
|
||||||
|
characterFiles.map((file) => uploadToS3(file).then(({ url }) => url))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use API to create story and generate first page
|
||||||
|
const response = await fetch("/api/generate-comic", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt,
|
||||||
|
apiKey,
|
||||||
|
style,
|
||||||
|
characterImages: characterUploads,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
if (response.status === 429 && errorData.isRateLimited) {
|
||||||
|
throw new Error(errorData.error);
|
||||||
|
}
|
||||||
|
throw new Error(errorData.error || "Failed to create story");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
// Redirect to the story editor using slug
|
||||||
|
router.push(`/editor/${result.storySlug}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating comic:", error);
|
||||||
|
toast({
|
||||||
|
title: "Creation failed",
|
||||||
|
description:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to create comic. Please try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
duration: 4000,
|
||||||
|
});
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
const isEnter = e.key === "Enter" || e.key === "\n" || e.keyCode === 13;
|
||||||
|
const isModifierPressed = e.shiftKey || e.ctrlKey || e.metaKey; // metaKey for Cmd on Mac
|
||||||
|
|
||||||
|
if (isEnter && isModifierPressed) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleCreate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadingSteps = [
|
||||||
|
"Enhancing prompt...",
|
||||||
|
"Generating scenes...",
|
||||||
|
"Creating your comic...",
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="relative glass-panel p-0.5 sm:p-1 rounded-xl group focus-within:border-indigo/30 transition-colors">
|
<div className="relative glass-panel p-0.5 sm:p-1 rounded-xl group focus-within:border-indigo/30 transition-colors">
|
||||||
@@ -95,6 +211,7 @@ export function StoryInput({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
placeholder="A cyberpunk detective standing in neon rain, holding a glowing datapad, moody lighting, noir style..."
|
placeholder="A cyberpunk detective standing in neon rain, holding a glowing datapad, moody lighting, noir style..."
|
||||||
@@ -242,6 +359,48 @@ export function StoryInput({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
{!isLoaded ? (
|
||||||
|
<div className="h-10" />
|
||||||
|
) : isSignedIn ? (
|
||||||
|
<div className="flex items-center justify-between gap-3 w-full">
|
||||||
|
<Button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={isLoading || !prompt.trim()}
|
||||||
|
className="bg-white hover:bg-neutral-200 text-black px-8 py-2 rounded-md text-sm font-medium transition-colors flex items-center justify-center gap-3 tracking-tight"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
<span className="text-sm font-medium tracking-tight">
|
||||||
|
{loadingSteps[loadingStep]}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Generate
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<div className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{hasApiKey ? (
|
||||||
|
<>Using your API key (~$0.01 per comic)</>
|
||||||
|
) : (
|
||||||
|
<>1 credit weekly</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SignInButton mode="modal">
|
||||||
|
<Button className="w-full sm:w-auto sm:min-w-40 bg-white hover:bg-neutral-200 text-black px-8 py-2 rounded-md text-sm font-medium transition-colors flex items-center justify-center gap-3 tracking-tight">
|
||||||
|
Login to create your comic
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</SignInButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { ArrowRight, Loader2 } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
import { useS3Upload } from "next-s3-upload";
|
|
||||||
import { useAuth, SignInButton } from "@clerk/nextjs";
|
|
||||||
|
|
||||||
interface CreateButtonProps {
|
|
||||||
prompt: string;
|
|
||||||
style: string;
|
|
||||||
characterFiles: File[];
|
|
||||||
isLoading: boolean;
|
|
||||||
setIsLoading: (loading: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CreateButton({
|
|
||||||
prompt,
|
|
||||||
style,
|
|
||||||
characterFiles,
|
|
||||||
isLoading,
|
|
||||||
setIsLoading,
|
|
||||||
}: CreateButtonProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [loadingStep, setLoadingStep] = useState(0);
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { uploadToS3 } = useS3Upload();
|
|
||||||
const { isSignedIn, isLoaded } = useAuth();
|
|
||||||
const [hasApiKey, setHasApiKey] = useState(false);
|
|
||||||
|
|
||||||
// Check if user has their own API key set
|
|
||||||
useEffect(() => {
|
|
||||||
const checkApiKey = () => {
|
|
||||||
const apiKey = localStorage.getItem("together_api_key");
|
|
||||||
setHasApiKey(!!apiKey);
|
|
||||||
};
|
|
||||||
|
|
||||||
checkApiKey();
|
|
||||||
// Listen for storage changes
|
|
||||||
window.addEventListener("storage", checkApiKey);
|
|
||||||
return () => window.removeEventListener("storage", checkApiKey);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isLoading) return;
|
|
||||||
|
|
||||||
const steps = [
|
|
||||||
"Enhancing prompt...",
|
|
||||||
"Generating scenes...",
|
|
||||||
"Creating your comic...",
|
|
||||||
];
|
|
||||||
let currentStep = 0;
|
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
currentStep += 1;
|
|
||||||
if (currentStep < steps.length) {
|
|
||||||
setLoadingStep(currentStep);
|
|
||||||
} else {
|
|
||||||
clearInterval(interval);
|
|
||||||
}
|
|
||||||
}, 2500);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [isLoading]);
|
|
||||||
|
|
||||||
const handleCreate = async () => {
|
|
||||||
if (!prompt.trim()) {
|
|
||||||
toast({
|
|
||||||
title: "Prompt required",
|
|
||||||
description: "Please enter a prompt to generate your comic",
|
|
||||||
variant: "destructive",
|
|
||||||
duration: 3000,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
setLoadingStep(0);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const apiKey = localStorage.getItem("together_api_key");
|
|
||||||
const characterUploads = await Promise.all(
|
|
||||||
characterFiles.map((file) => uploadToS3(file).then(({ url }) => url))
|
|
||||||
);
|
|
||||||
|
|
||||||
// Use API to create story and generate first page
|
|
||||||
const response = await fetch("/api/generate-comic", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
prompt,
|
|
||||||
apiKey,
|
|
||||||
style,
|
|
||||||
characterImages: characterUploads,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
if (response.status === 429 && errorData.isRateLimited) {
|
|
||||||
throw new Error(errorData.error);
|
|
||||||
}
|
|
||||||
throw new Error(errorData.error || "Failed to create story");
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
// Redirect to the story editor using slug
|
|
||||||
router.push(`/editor/${result.storySlug}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error creating comic:", error);
|
|
||||||
toast({
|
|
||||||
title: "Creation failed",
|
|
||||||
description:
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to create comic. Please try again.",
|
|
||||||
variant: "destructive",
|
|
||||||
duration: 4000,
|
|
||||||
});
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadingSteps = [
|
|
||||||
"Enhancing prompt...",
|
|
||||||
"Generating scenes...",
|
|
||||||
"Creating your comic...",
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="pt-2">
|
|
||||||
{!isLoaded ? (
|
|
||||||
<div className="h-10" />
|
|
||||||
) : isSignedIn ? (
|
|
||||||
<div className="flex items-center justify-between gap-3 w-full">
|
|
||||||
<Button
|
|
||||||
onClick={handleCreate}
|
|
||||||
disabled={isLoading || !prompt.trim()}
|
|
||||||
className="bg-white hover:bg-neutral-200 text-black px-8 py-2 rounded-md text-sm font-medium transition-colors flex items-center justify-center gap-3 tracking-tight"
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
<span className="text-sm font-medium tracking-tight">
|
|
||||||
{loadingSteps[loadingStep]}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Generate
|
|
||||||
<ArrowRight className="w-4 h-4" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<div className="text-xs text-muted-foreground whitespace-nowrap">
|
|
||||||
{hasApiKey ? (
|
|
||||||
<>Using your API key (~$0.01 per comic)</>
|
|
||||||
) : (
|
|
||||||
<>1 credit weekly</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<SignInButton mode="modal">
|
|
||||||
<Button className="w-full sm:w-auto sm:min-w-40 bg-white hover:bg-neutral-200 text-black px-8 py-2 rounded-md text-sm font-medium transition-colors flex items-center justify-center gap-3 tracking-tight">
|
|
||||||
Login to create your comic
|
|
||||||
<ArrowRight className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</SignInButton>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -6,15 +6,17 @@ import { Github, Key, BookOpen, User, Plus } from "lucide-react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ApiKeyModal } from "@/components/api-key-modal";
|
import { ApiKeyModal } from "@/components/api-key-modal";
|
||||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
||||||
|
import { useApiKey } from "@/hooks/use-api-key";
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const [showApiModal, setShowApiModal] = useState(false);
|
const [showApiModal, setShowApiModal] = useState(false);
|
||||||
|
|
||||||
const { isLoaded } = useAuth();
|
const { isLoaded } = useAuth();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const [, setApiKey] = useApiKey();
|
||||||
|
|
||||||
const handleApiKeySubmit = (key: string) => {
|
const handleApiKeySubmit = (key: string) => {
|
||||||
localStorage.setItem("together_api_key", key);
|
setApiKey(key);
|
||||||
setShowApiModal(false);
|
setShowApiModal(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { Check } from "lucide-react"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { COMIC_STYLES } from "@/lib/constants"
|
|
||||||
|
|
||||||
interface StyleSelectorProps {
|
|
||||||
style: string
|
|
||||||
setStyle: (style: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StyleSelector({ style, setStyle }: StyleSelectorProps) {
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label className="text-base font-semibold font-display">Choose Your Style</Label>
|
|
||||||
|
|
||||||
{/* Grid for style selection */}
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{COMIC_STYLES.map((styleOption) => (
|
|
||||||
<button
|
|
||||||
key={styleOption.id}
|
|
||||||
onClick={() => setStyle(styleOption.id)}
|
|
||||||
className={`
|
|
||||||
relative text-left transition-all duration-200 rounded-lg p-3.5 border-2 group
|
|
||||||
hover:scale-[1.02] active:scale-[0.98]
|
|
||||||
${
|
|
||||||
style === styleOption.id
|
|
||||||
? "border-indigo bg-indigo shadow-md"
|
|
||||||
: "border-border hover:border-indigo/50 bg-card hover:bg-muted/20"
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className={`font-semibold text-sm font-display ${
|
|
||||||
style === styleOption.id ? "text-white" : "text-foreground"
|
|
||||||
}`}>{styleOption.name}</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{style === styleOption.id && (
|
|
||||||
<div className="bg-white text-indigo p-1 rounded-full shrink-0">
|
|
||||||
<Check className="w-3 h-3" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
const loaderSvgs = ["bang.svg", "oh.svg", "omg.svg"];
|
||||||
|
|
||||||
|
interface StoryLoaderProps {
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StoryLoader({ text = "Loading story..." }: StoryLoaderProps) {
|
||||||
|
const [currentSvgIndex, setCurrentSvgIndex] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setCurrentSvgIndex((prev) => (prev + 1) % loaderSvgs.length);
|
||||||
|
}, 1200); // Change every 1200ms for slower transition
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-8">
|
||||||
|
<div className="w-56 h-56 flex items-center justify-center">
|
||||||
|
<img
|
||||||
|
key={currentSvgIndex} // Force re-render for smooth transition
|
||||||
|
src={`/loader/${loaderSvgs[currentSvgIndex]}`}
|
||||||
|
alt="Loading..."
|
||||||
|
className="w-full h-full object-contain animate-pulse transition-all duration-700 ease-in-out transform scale-90 hover:scale-110"
|
||||||
|
style={{ filter: 'invert(1)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-white text-lg">{text}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "together_api_key";
|
||||||
|
const STORAGE_EVENT = "apiKeyChanged";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive hook for managing the Together API key in localStorage.
|
||||||
|
* Automatically syncs across components and tabs when the key changes.
|
||||||
|
*
|
||||||
|
* @returns {[string | null, (key: string | null) => void]} Tuple of [apiKey, setApiKey]
|
||||||
|
*/
|
||||||
|
export function useApiKey(): [string | null, (key: string | null) => void] {
|
||||||
|
const [apiKey, setApiKeyState] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Initialize from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const readFromStorage = () => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
setApiKeyState(stored);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
readFromStorage();
|
||||||
|
|
||||||
|
// Listen for storage events (cross-tab updates)
|
||||||
|
const handleStorageChange = (e: StorageEvent) => {
|
||||||
|
if (e.key === STORAGE_KEY) {
|
||||||
|
setApiKeyState(e.newValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for custom events (same-tab updates)
|
||||||
|
const handleCustomStorageChange = () => {
|
||||||
|
readFromStorage();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("storage", handleStorageChange);
|
||||||
|
window.addEventListener(STORAGE_EVENT, handleCustomStorageChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("storage", handleStorageChange);
|
||||||
|
window.removeEventListener(STORAGE_EVENT, handleCustomStorageChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Setter function that updates both localStorage and state, and dispatches event
|
||||||
|
const setApiKey = useCallback((key: string | null) => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
if (key === null) {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
} else {
|
||||||
|
localStorage.setItem(STORAGE_KEY, key);
|
||||||
|
}
|
||||||
|
setApiKeyState(key);
|
||||||
|
// Dispatch custom event for same-tab reactivity
|
||||||
|
window.dispatchEvent(new CustomEvent(STORAGE_EVENT));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return [apiKey, setApiKey];
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useKeyboardShortcut(
|
||||||
|
callback: () => void,
|
||||||
|
options: {
|
||||||
|
ctrlOrCmd?: boolean;
|
||||||
|
shift?: boolean;
|
||||||
|
key?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
ctrlOrCmd = true,
|
||||||
|
shift = false,
|
||||||
|
key = 'Enter',
|
||||||
|
disabled = false,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
const isKeyPressed = e.key === key;
|
||||||
|
const isModifierPressed = ctrlOrCmd
|
||||||
|
? (e.ctrlKey || e.metaKey) // Ctrl on Windows/Linux, Cmd on Mac
|
||||||
|
: shift
|
||||||
|
? e.shiftKey
|
||||||
|
: false;
|
||||||
|
|
||||||
|
if (isKeyPressed && isModifierPressed) {
|
||||||
|
e.preventDefault();
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [callback, ctrlOrCmd, shift, key, disabled]);
|
||||||
|
}
|
||||||
+48
-5
@@ -42,6 +42,12 @@ export async function updatePage(pageId: string, generatedImageUrl: string): Pro
|
|||||||
.where(eq(pages.id, pageId));
|
.where(eq(pages.id, pageId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateStory(storyId: string, data: { title?: string; description?: string }): Promise<void> {
|
||||||
|
await db.update(stories)
|
||||||
|
.set({ ...data, updatedAt: new Date() })
|
||||||
|
.where(eq(stories.id, storyId));
|
||||||
|
}
|
||||||
|
|
||||||
export async function getStoryWithPages(storyId: string): Promise<{ story: Story; pages: Page[] } | null> {
|
export async function getStoryWithPages(storyId: string): Promise<{ story: Story; pages: Page[] } | null> {
|
||||||
const storyResult = await db.select().from(stories).where(eq(stories.id, storyId)).limit(1);
|
const storyResult = await db.select().from(stories).where(eq(stories.id, storyId)).limit(1);
|
||||||
|
|
||||||
@@ -82,13 +88,46 @@ export async function getStoryWithPagesBySlug(slug: string): Promise<{ story: St
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getStoryCharacterImages(storyId: string): Promise<string[]> {
|
export async function getStoryCharacterImages(storyId: string): Promise<string[]> {
|
||||||
const storyPages = await db.select({ characterImageUrls: pages.characterImageUrls })
|
const storyPages = await db.select({
|
||||||
|
characterImageUrls: pages.characterImageUrls,
|
||||||
|
pageNumber: pages.pageNumber
|
||||||
|
})
|
||||||
.from(pages)
|
.from(pages)
|
||||||
.where(eq(pages.storyId, storyId));
|
.where(eq(pages.storyId, storyId))
|
||||||
|
.orderBy(pages.pageNumber);
|
||||||
|
|
||||||
// Flatten all character URLs from all pages and remove duplicates
|
// Flatten all character URLs from all pages, keeping order by page number
|
||||||
const allUrls = storyPages.flatMap(page => page.characterImageUrls);
|
const allUrls: string[] = [];
|
||||||
return [...new Set(allUrls)]; // Remove duplicates
|
const seenUrls = new Set<string>();
|
||||||
|
|
||||||
|
for (const page of storyPages) {
|
||||||
|
for (const url of page.characterImageUrls) {
|
||||||
|
if (!seenUrls.has(url)) {
|
||||||
|
seenUrls.add(url);
|
||||||
|
allUrls.push(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allUrls;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLastPageImage(storyId: string): Promise<string | null> {
|
||||||
|
const allPages = await db.select({ generatedImageUrl: pages.generatedImageUrl, pageNumber: pages.pageNumber })
|
||||||
|
.from(pages)
|
||||||
|
.where(eq(pages.storyId, storyId))
|
||||||
|
.orderBy(pages.pageNumber);
|
||||||
|
|
||||||
|
if (allPages.length === 0) return null;
|
||||||
|
|
||||||
|
// Find the last page that has a generated image
|
||||||
|
for (let i = allPages.length - 1; i >= 0; i--) {
|
||||||
|
if (allPages[i].generatedImageUrl) {
|
||||||
|
return allPages[i].generatedImageUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getNextPageNumber(storyId: string): Promise<number> {
|
export async function getNextPageNumber(storyId: string): Promise<number> {
|
||||||
@@ -102,4 +141,8 @@ export async function getNextPageNumber(storyId: string): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Math.max(...storyPages.map(p => p.pageNumber)) + 1;
|
return Math.max(...storyPages.map(p => p.pageNumber)) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePage(pageId: string): Promise<void> {
|
||||||
|
await db.delete(pages).where(eq(pages.id, pageId));
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { COMIC_STYLES } from "./constants";
|
||||||
|
|
||||||
|
export function buildComicPrompt({
|
||||||
|
prompt,
|
||||||
|
style,
|
||||||
|
characterImages = [],
|
||||||
|
isContinuation = false,
|
||||||
|
previousContext = "",
|
||||||
|
isAddPage = false,
|
||||||
|
previousPages = [],
|
||||||
|
}: {
|
||||||
|
prompt: string;
|
||||||
|
style?: string;
|
||||||
|
characterImages?: string[];
|
||||||
|
isContinuation?: boolean;
|
||||||
|
previousContext?: string;
|
||||||
|
isAddPage?: boolean;
|
||||||
|
previousPages?: Array<{
|
||||||
|
prompt: string;
|
||||||
|
characterImages: string[];
|
||||||
|
}>;
|
||||||
|
}): string {
|
||||||
|
const styleInfo = COMIC_STYLES.find((s) => s.id === style);
|
||||||
|
const styleDesc = styleInfo?.prompt || COMIC_STYLES[2].prompt;
|
||||||
|
|
||||||
|
let continuationContext = "";
|
||||||
|
if (isContinuation && previousContext) {
|
||||||
|
continuationContext = `\nCONTINUATION CONTEXT:\nThis is a continuation of an existing story. The previous page showed: ${previousContext}\nMaintain visual consistency with the previous panels. Continue the narrative naturally.\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAddPage && previousPages.length > 0) {
|
||||||
|
const storyHistory = previousPages
|
||||||
|
.map((page, index) => `Page ${index + 1}: ${page.prompt}`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
continuationContext = `\nSTORY CONTINUATION CONTEXT:\nThis is a continuation of an existing comic story. Here are the previous pages:\n${storyHistory}\n\nThe new page should naturally continue this story. Maintain the same characters, setting, and narrative style. Reference previous events and build upon them.\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let characterSection = "";
|
||||||
|
if (characterImages.length > 0) {
|
||||||
|
if (characterImages.length === 1) {
|
||||||
|
characterSection = `
|
||||||
|
CRITICAL FACE CONSISTENCY INSTRUCTIONS:
|
||||||
|
- REFERENCE CHARACTER: Use the uploaded image as EXACT reference for the protagonist's face and appearance
|
||||||
|
- FACE MATCHING: The character's face must be IDENTICAL to the reference image - same eyes, nose, mouth, hair, facial structure
|
||||||
|
- APPEARANCE PRESERVATION: Maintain exact skin tone, hair color/style, eye color, and distinctive facial features
|
||||||
|
- CHARACTER CONSISTENCY: This exact same character must appear in ALL 5 panels with the same face throughout
|
||||||
|
- STYLE APPLICATION: Apply ${style} comic art style to the body/pose/action but KEEP THE FACE EXACTLY AS IN THE REFERENCE IMAGE
|
||||||
|
- NO VARIATION: Do not alter, modify, or change the character's face in any way from the reference`;
|
||||||
|
} else if (characterImages.length === 2) {
|
||||||
|
characterSection = `
|
||||||
|
CRITICAL DUAL CHARACTER FACE CONSISTENCY INSTRUCTIONS:
|
||||||
|
- CHARACTER 1 REFERENCE: Use the FIRST uploaded image as EXACT reference for Character 1's face and appearance
|
||||||
|
- CHARACTER 2 REFERENCE: Use the SECOND uploaded image as EXACT reference for Character 2's face and appearance
|
||||||
|
- FACE MATCHING: Both characters' faces must be IDENTICAL to their respective reference images
|
||||||
|
- VISUAL DISTINCTION: Keep both characters clearly visually distinct with their unique faces, hair, and features
|
||||||
|
- CONSISTENT PRESENCE: Both characters must appear together in at least 4 of the 5 panels
|
||||||
|
- STYLE APPLICATION: Apply ${style} comic art style while maintaining EXACT facial features from references
|
||||||
|
- NO FACE VARIATION: Never alter or modify either character's face from their reference images`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemPrompt = `Professional comic book page illustration.
|
||||||
|
${continuationContext}
|
||||||
|
${characterSection}
|
||||||
|
|
||||||
|
CHARACTER CONSISTENCY RULES (HIGHEST PRIORITY):
|
||||||
|
- If reference images are provided, the characters' FACES must be 100% identical to the reference images
|
||||||
|
- Never change hair color, eye color, facial structure, or distinctive features
|
||||||
|
- Apply comic style to body/pose/action but preserve exact facial appearance
|
||||||
|
- Same character must look identical across all panels they appear in
|
||||||
|
|
||||||
|
TEXT AND LETTERING (CRITICAL):
|
||||||
|
- All text in speech bubbles must be PERFECTLY CLEAR, LEGIBLE, and correctly spelled
|
||||||
|
- Use bold clean comic book lettering, large and easy to read
|
||||||
|
- Speech bubbles: crisp white fill, solid black outline, pointed tail toward speaker
|
||||||
|
- Keep dialogue SHORT: maximum 1-2 sentences per bubble
|
||||||
|
- NO blurry, warped, or unreadable text
|
||||||
|
|
||||||
|
PAGE LAYOUT:
|
||||||
|
5-panel comic page arranged as:
|
||||||
|
[Panel 1] [Panel 2] — top row, 2 equal panels
|
||||||
|
[ Panel 3 ] — middle row, 1 large cinematic hero panel
|
||||||
|
[Panel 4] [Panel 5] — bottom row, 2 equal panels
|
||||||
|
- Solid black panel borders with clean white gutters between panels
|
||||||
|
- Each panel clearly separated and distinct
|
||||||
|
|
||||||
|
ART STYLE:
|
||||||
|
${styleDesc}
|
||||||
|
${characterSection}
|
||||||
|
|
||||||
|
COMPOSITION:
|
||||||
|
- Vary camera angles across panels: close-up, medium shot, wide establishing shot
|
||||||
|
- Natural visual flow: left-to-right, top-to-bottom reading order
|
||||||
|
- Dynamic character poses with clear expressive acting
|
||||||
|
- Detailed backgrounds matching the scene and mood`;
|
||||||
|
|
||||||
|
return `${systemPrompt}\n\nSTORY:\n${prompt}`;
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 57 KiB |
@@ -0,0 +1,84 @@
|
|||||||
|
<svg width="451" height="363" viewBox="0 0 451 363" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M144.004 0.852005C142.529 1.14699 141.349 2.17942 141.349 2.91687C141.349 4.68675 174.829 4.68675 175.862 2.91687C176.304 2.17942 175.714 1.14699 174.387 0.704515C171.732 -0.327915 147.838 -0.180425 144.004 0.852005Z" fill="black" />
|
||||||
|
<path d="M127.337 5.86681C126.747 6.60426 124.83 7.93167 123.208 8.81661C114.358 12.6514 97.1019 26.0729 97.1019 29.1702C97.1019 29.9077 96.0694 31.2351 94.8895 32.2675C93.7096 33.3 92.6772 35.2173 92.6772 36.6922C92.6772 38.1671 91.6447 39.642 90.4648 40.232C89.2849 40.6745 88.2525 42.2968 88.2525 43.9192C88.2525 45.5416 87.515 47.0165 86.6301 47.3115C80.878 49.2289 77.0432 81.2342 81.4679 90.0836C82.7953 92.5909 83.8278 96.7207 83.8278 99.3755C83.8278 102.915 84.7127 104.39 87.515 105.718C90.6123 107.192 91.4972 107.045 92.2347 105.128C92.8246 103.653 92.0872 102.178 90.7598 101.293C89.2849 100.555 88.2525 98.4905 88.2525 96.7207C88.2525 94.9508 87.22 92.1485 86.0401 90.6736C82.9428 86.5439 83.0903 60.7331 86.0401 56.7509C87.22 55.1285 88.2525 52.7686 88.2525 51.2937C88.2525 49.9663 89.2849 48.049 90.4648 47.0165C91.6447 45.9841 92.6772 44.0667 92.6772 42.7393C92.6772 37.2822 118.34 13.0938 124.093 13.0938C125.272 13.0938 127.042 12.0614 128.075 10.8815C129.107 9.70155 131.467 8.66912 133.237 8.66912C135.007 8.66912 137.367 7.63669 138.399 6.45677C140.021 4.5394 139.432 4.24442 134.269 4.24442C130.877 4.24442 127.78 4.98187 127.337 5.86681Z" fill="black" />
|
||||||
|
<path d="M180.434 5.71923C179.844 6.60417 181.466 7.6366 183.973 8.07907C186.481 8.66903 189.283 9.99644 190.168 11.0289C191.053 12.2088 192.823 13.0937 194.003 13.0937C196.658 13.0937 212.144 29.0227 212.144 31.825C212.144 32.8574 213.177 34.1848 214.356 34.6273C215.536 35.2172 216.569 37.1346 216.569 38.9045C216.569 42.2968 218.929 45.0991 220.404 43.6242C222.026 42.0018 218.929 32.1199 215.684 28.5802C213.766 26.5153 212.144 24.4505 212.144 24.008C212.144 22.2381 195.625 8.66903 193.265 8.66903C192.528 8.66903 191.053 7.6366 190.021 6.45668C187.956 3.94935 181.761 3.50688 180.434 5.71923Z" fill="black" />
|
||||||
|
<path d="M300.933 14.8636C301.818 17.5184 305.063 17.9609 305.063 15.4536C305.063 14.1262 304.03 13.0937 302.703 13.0937C301.376 13.0937 300.638 13.8312 300.933 14.8636Z" fill="black" />
|
||||||
|
<path d="M362.879 24.3029C363.026 25.6303 364.206 27.1052 365.239 27.5477C366.566 27.9901 367.156 27.2527 366.714 25.4828C366.566 24.1554 365.386 22.6805 364.354 22.238C363.026 21.7956 362.436 22.533 362.879 24.3029Z" fill="black" />
|
||||||
|
<path d="M367.304 31.6774C367.451 33.0048 368.631 34.4797 369.663 34.9222C370.991 35.3647 371.581 34.6272 371.138 32.8573C370.991 31.5299 369.811 30.055 368.779 29.6125C367.451 29.1701 366.861 29.9075 367.304 31.6774Z" fill="black" />
|
||||||
|
<path d="M372.318 40.6744C371.876 41.1169 371.433 48.0489 371.433 56.0134C371.433 68.55 371.876 70.7624 374.088 71.9423C376.448 73.2697 376.743 72.8272 376.153 68.9925C375.711 66.1902 376.448 63.5354 377.923 61.913C380.135 59.5531 380.135 59.1107 378.07 57.3408C376.595 56.1609 375.858 52.9161 375.858 48.3439C375.858 41.7068 374.531 38.6096 372.318 40.6744Z" fill="black" />
|
||||||
|
<path d="M220.993 49.5237C220.993 50.9986 221.731 53.211 222.468 54.3909C223.501 56.0133 223.943 55.4233 223.943 51.7361C223.943 49.2287 223.353 47.0164 222.468 47.0164C221.731 47.0164 220.993 48.1963 220.993 49.5237Z" fill="black" />
|
||||||
|
<path d="M38.8432 81.0865C37.3683 83.2989 38.9907 84.4788 41.793 83.2989C43.1204 82.8564 43.7104 81.824 43.2679 80.9391C42.088 79.0217 40.0232 79.0217 38.8432 81.0865Z" fill="black" />
|
||||||
|
<path d="M393.262 80.3493C392.819 80.7917 395.622 84.479 399.604 88.4612C403.586 92.5909 406.831 97.0156 406.831 98.343C406.831 99.8179 408.011 102.62 409.338 104.538C413.173 109.995 413.173 144.802 409.486 150.702C408.011 152.767 406.831 156.159 406.831 158.077C406.831 160.141 405.799 162.501 404.619 163.534C403.439 164.566 402.406 166.041 402.406 166.778C402.406 170.171 387.215 188.017 382.495 190.229C380.873 190.967 378.513 192.147 377.333 192.884C372.171 195.981 363.616 198.931 360.372 198.931C355.504 198.931 355.799 200.996 361.699 208.223C370.253 218.99 372.318 230.789 366.861 237.869L363.911 241.703L371.728 245.243C376.006 247.308 381.61 250.995 384.265 253.798C388.395 258.075 388.985 259.845 389.28 267.367C389.575 274.004 389.132 276.363 387.215 277.838C383.97 280.198 383.97 280.493 389.132 286.245C392.819 290.228 393.557 292.292 393.557 298.782C393.557 307.779 389.87 315.891 383.233 321.348C378.218 325.478 377.628 327.248 381.315 327.248C382.79 327.248 386.92 324.003 390.607 320.021C398.277 311.761 402.259 301.437 399.604 296.57C397.244 292.145 397.539 284.918 400.194 283.885C401.374 283.443 402.406 281.673 402.406 280.051C402.406 277.691 401.374 277.101 397.834 277.101C393.557 277.101 393.409 276.806 393.852 271.644C394.147 267.219 395.032 266.039 397.982 265.302C403.439 263.974 403.144 257.337 397.687 256.747C394.294 256.305 393.557 255.567 393.557 252.175C393.557 248.635 392.819 247.75 389.132 247.013C385.15 246.276 384.707 245.538 384.707 240.966C384.707 236.246 385.002 235.804 389.132 235.804C393.262 235.804 393.557 236.246 393.557 240.966C393.557 245.686 393.852 246.128 397.834 246.128C400.784 246.128 402.554 245.243 403.144 243.326C404.471 238.754 402.406 235.804 397.687 235.804C393.704 235.804 393.409 235.509 393.852 231.084C394.294 226.954 395.032 226.069 398.719 225.479C401.964 224.889 403.291 223.857 403.586 221.202C404.176 217.22 401.816 215.155 396.802 215.155C393.852 215.155 393.409 214.418 393.852 211.025C394.147 207.928 395.179 206.896 398.277 206.601C405.356 205.716 405.651 194.507 398.719 194.507C391.787 194.507 392.377 188.754 399.456 186.1C402.849 184.772 403.881 183.445 403.881 180.642C403.881 177.545 404.619 176.808 407.568 176.808C412.288 176.808 414.943 172.088 412.583 168.253C411.551 166.631 411.256 164.271 411.846 162.796C412.731 160.289 413.026 160.289 415.533 162.649C418.483 165.304 423.055 164.419 423.055 161.174C423.055 159.994 421.432 158.371 419.368 157.634C414.648 155.864 414.353 148.932 418.63 145.097C422.17 142 422.465 138.018 419.368 135.511C416.565 133.151 416.565 129.021 419.368 124.596C421.432 121.499 421.432 120.909 418.63 117.369C417.008 115.304 415.68 111.765 415.68 109.552C415.68 107.34 414.648 104.39 413.468 103.063C412.141 101.588 410.961 97.9006 410.813 94.8033C410.518 89.7886 409.928 89.0512 406.831 88.6087C404.176 88.3137 402.554 86.8388 401.374 83.889C399.899 79.7593 395.622 77.9894 393.262 80.3493ZM393.262 200.111C393.704 204.683 393.557 204.978 389.575 204.536C386.477 204.241 385.297 203.208 385.002 200.554C384.413 196.866 386.92 194.212 390.607 194.949C391.935 195.096 392.967 197.161 393.262 200.111ZM382.495 210.73C382.495 213.385 381.61 214.565 379.103 214.86C375.563 215.45 373.498 212.205 374.973 208.371C375.416 207.043 377.186 206.306 379.103 206.601C381.61 206.896 382.495 208.076 382.495 210.73ZM393.557 221.055C393.557 225.037 393.114 225.479 389.132 225.479C385.15 225.479 384.707 225.037 384.707 221.055C384.707 217.073 385.15 216.63 389.132 216.63C393.114 216.63 393.557 217.073 393.557 221.055ZM380.873 226.364C382.2 226.807 383.233 228.872 383.233 230.789C383.233 233.591 382.495 234.329 379.545 234.329C375.121 234.329 373.203 230.789 375.858 227.692C377.923 225.184 377.775 225.184 380.873 226.364Z" fill="black" />
|
||||||
|
<path d="M30.2889 84.9213C28.3716 86.6912 29.2565 88.3136 32.2063 88.3136C33.8287 88.3136 35.1561 87.2812 35.1561 86.1013C35.1561 83.8889 32.0588 83.004 30.2889 84.9213Z" fill="black" />
|
||||||
|
<path d="M47.9877 89.1987C45.9228 91.2636 46.9552 92.886 49.905 92.4435C51.6749 92.1485 53.0023 91.2636 53.0023 90.5261C53.0023 88.7562 49.4626 87.8713 47.9877 89.1987Z" fill="black" />
|
||||||
|
<path d="M37.221 94.9506C36.6311 96.1305 35.3036 97.1629 34.1237 97.1629C32.0589 97.1629 21.8821 107.192 21.8821 109.257C21.8821 109.995 20.8496 110.437 19.6697 110.437C18.4898 110.437 17.4574 111.027 17.4574 111.617C17.4574 112.354 15.54 114.862 13.0327 117.222C10.6728 119.581 8.60795 122.531 8.60795 123.859C8.60795 125.186 7.57552 126.661 6.3956 127.251C3.74078 128.283 3.29831 134.625 6.10062 133.74C6.98556 133.298 8.16548 131.823 8.46046 130.348C8.90293 127.693 10.0829 125.923 14.95 120.614C16.2774 119.139 17.4574 117.517 17.4574 117.074C17.4574 115.599 32.0589 101.588 33.3863 101.588C34.2712 101.588 35.1562 100.703 35.5986 99.5228C36.0411 98.3428 37.9585 97.1629 39.7284 96.8679C41.4982 96.573 43.2681 95.5405 43.7106 94.5081C44.5955 91.8533 38.2534 92.2957 37.221 94.9506Z" fill="black" />
|
||||||
|
<path d="M312.732 98.7854C308.16 100.555 308.308 103.505 316.272 148.047C317.452 154.537 318.337 161.911 318.337 164.566C318.337 169.286 321.729 184.33 323.499 187.132C323.942 188.017 329.399 188.607 335.298 188.607C348.425 188.607 349.605 187.427 347.687 175.333C347.097 170.908 345.77 152.029 345.033 133.593C344.148 115.157 343.115 99.3754 342.673 98.6379C341.493 96.7205 317.305 96.868 312.732 98.7854Z" fill="black" />
|
||||||
|
<path d="M263.766 103.948C257.719 104.98 252.556 105.865 252.409 105.865C251.376 106.16 252.114 115.157 253.589 118.697C254.916 122.531 258.161 156.307 258.456 170.171L258.603 176.07L250.491 176.955C246.067 177.398 240.61 178.283 238.545 178.872C235.3 179.757 234.415 179.315 232.94 175.628C231.908 173.12 230.728 159.699 230.138 143.77C229.4 126.956 228.368 115.747 227.336 114.714C226.303 113.682 220.846 113.534 209.047 114.419L192.233 115.599L192.38 122.236C192.675 136.985 195.92 183.74 196.658 186.1C197.248 187.279 198.133 199.521 198.575 213.238L199.607 238.016L214.946 237.721C224.533 237.574 231.023 236.689 232.35 235.509C234.12 234.034 234.415 230.199 234.12 214.565C233.53 193.032 232.793 194.359 246.657 191.704C257.719 189.639 260.373 190.967 261.258 199.226C261.553 202.766 262.733 211.173 263.913 217.957L265.83 230.199L271.878 229.462C279.989 228.429 299.458 225.184 301.965 224.594C303.588 224.004 303.735 222.087 302.998 215.45C302.408 210.878 301.376 198.046 300.638 187.132C299.901 176.218 298.573 163.681 297.836 159.256C296.951 154.979 296.213 148.342 296.213 144.507C296.213 140.82 295.181 133.003 294.001 127.398C292.821 121.646 291.789 114.419 291.789 111.027C291.789 107.782 291.051 104.39 290.019 103.358C287.806 101.145 278.22 101.293 263.766 103.948Z" fill="black" />
|
||||||
|
<path d="M94.152 121.351C92.0871 122.384 88.1049 124.449 85.3026 125.924C77.4856 129.906 66.7188 142.147 61.9991 152.324C51.0849 175.775 53.1497 200.406 67.8987 220.612C77.6331 234.034 88.8423 241.408 106.099 245.538C115.243 247.75 116.865 247.75 128.075 245.538C148.723 241.261 160.522 231.821 169.519 212.205C176.009 198.194 178.221 180.2 174.682 169.876C173.354 166.041 172.322 161.911 172.322 160.436C172.322 156.896 165.685 143.18 160.522 136.1C157.425 131.823 147.691 124.891 141.496 122.384C133.974 119.287 99.1666 118.549 94.152 121.351ZM126.157 157.929C137.957 164.861 141.349 170.613 141.349 183.74C141.349 192.147 140.759 194.801 137.662 199.374C128.517 212.795 112.883 216.188 101.379 207.338C95.0369 202.471 94.7419 202.028 90.6122 192.589C85.745 181.232 89.4323 167.663 99.6091 159.404C106.836 153.504 117.455 152.914 126.157 157.929Z" fill="black" />
|
||||||
|
<path d="M426.005 130.348C426.005 131.676 427.332 132.561 429.692 132.561C432.052 132.561 433.379 131.676 433.379 130.348C433.379 129.021 432.052 128.136 429.692 128.136C427.332 128.136 426.005 129.021 426.005 130.348Z" fill="black" />
|
||||||
|
<path d="M0.348338 139.198C-0.831583 142.295 1.23328 145.245 2.85567 142.737C4.62555 139.935 4.47806 136.985 2.70818 136.985C1.82324 136.985 0.790808 138.018 0.348338 139.198Z" fill="black" />
|
||||||
|
<path d="M426.89 148.195C423.645 151.439 424.677 153.947 429.102 153.947C431.904 153.947 432.642 153.209 432.642 150.407C432.642 145.982 430.134 144.95 426.89 148.195Z" fill="black" />
|
||||||
|
<path d="M437.066 160.436C436.624 161.174 436.919 162.206 437.804 162.796C439.574 163.829 442.229 162.501 442.229 160.436C442.229 158.666 438.246 158.666 437.066 160.436Z" fill="black" />
|
||||||
|
<path d="M427.037 167.663C425.267 167.958 424.53 169.286 424.825 171.351C425.415 175.923 432.494 175.923 433.084 171.498C433.527 168.106 431.314 166.631 427.037 167.663Z" fill="black" />
|
||||||
|
<path d="M446.653 170.023C446.653 171.35 447.686 172.383 449.013 172.383C451.52 172.383 451.078 169.138 448.423 168.253C447.391 167.958 446.653 168.696 446.653 170.023Z" fill="black" />
|
||||||
|
<path d="M415.68 181.232C415.68 185.214 416.123 185.804 419.073 185.362C421.432 185.067 422.317 183.887 422.317 181.232C422.317 178.577 421.432 177.398 419.073 177.103C416.123 176.66 415.68 177.25 415.68 181.232Z" fill="black" />
|
||||||
|
<path d="M438.099 178.578C436.181 180.495 437.509 184.182 440.164 184.182C442.376 184.182 442.966 179.02 441.049 177.693C440.311 177.25 438.984 177.693 438.099 178.578Z" fill="black" />
|
||||||
|
<path d="M405.651 187.427C402.259 190.819 403.586 194.506 408.158 194.506C413.468 194.506 414.943 192.589 412.731 188.607C410.961 185.214 408.306 184.772 405.651 187.427Z" fill="black" />
|
||||||
|
<path d="M425.12 189.197C423.792 192.441 425.71 194.506 429.839 194.506C432.937 194.506 433.527 193.916 433.084 191.114C432.494 187.279 426.447 185.657 425.12 189.197Z" fill="black" />
|
||||||
|
<path d="M415.975 199.963C416.565 204.683 418.483 205.568 422.317 202.766C424.677 200.996 424.825 200.406 423.202 198.341C422.17 197.014 419.958 195.981 418.335 195.981C416.123 195.981 415.533 196.866 415.975 199.963Z" fill="black" />
|
||||||
|
<path d="M436.919 199.079C435.444 201.586 438.836 204.388 440.901 202.324C442.671 200.554 441.491 197.456 439.279 197.456C438.541 197.456 437.509 198.194 436.919 199.079Z" fill="black" />
|
||||||
|
<path d="M330.136 202.914C322.172 206.158 320.254 218.99 326.744 225.774C334.856 234.181 349.31 229.609 349.31 218.4C349.31 207.338 339.428 199.374 330.136 202.914Z" fill="black" />
|
||||||
|
<path d="M405.651 206.601C401.816 210.435 405.356 217.662 410.223 215.745C414.058 214.27 414.943 209.108 411.846 206.748C408.601 204.388 407.863 204.388 405.651 206.601Z" fill="black" />
|
||||||
|
<path d="M426.005 207.78C423.055 209.55 425.857 214.417 429.839 214.417C433.084 214.417 434.559 209.403 431.609 207.633C429.102 206.011 428.659 206.011 426.005 207.78Z" fill="black" />
|
||||||
|
<path d="M446.653 209.993C446.653 211.32 447.391 212.205 448.276 211.91C449.161 211.615 449.898 210.731 449.898 209.993C449.898 209.256 449.161 208.371 448.276 208.076C447.391 207.781 446.653 208.666 446.653 209.993Z" fill="black" />
|
||||||
|
<path d="M415.68 221.055C415.68 225.037 416.123 225.627 419.073 225.184C421.432 224.889 422.317 223.709 422.317 221.055C422.317 218.4 421.432 217.22 419.073 216.925C416.123 216.482 415.68 217.072 415.68 221.055Z" fill="black" />
|
||||||
|
<path d="M437.804 221.055C437.804 224.594 440.016 225.037 441.344 221.645C442.671 218.4 442.523 218.105 440.016 218.105C438.836 218.105 437.804 219.432 437.804 221.055Z" fill="black" />
|
||||||
|
<path d="M405.061 229.314C403.586 232.411 403.734 233.296 405.504 234.476C408.306 236.246 408.748 236.099 411.846 233.886C414.058 232.264 414.353 231.379 412.878 228.724C410.518 224.447 407.126 224.594 405.061 229.314Z" fill="black" />
|
||||||
|
<path d="M425.562 227.839C424.972 228.429 424.53 230.199 424.53 231.674C424.53 233.591 425.71 234.329 429.102 234.329C432.937 234.329 433.527 233.886 433.084 230.937C432.642 227.692 427.775 225.627 425.562 227.839Z" fill="black" />
|
||||||
|
<path d="M446.948 231.231C447.538 233.149 449.603 233.591 449.751 231.821C449.751 231.231 449.013 230.494 448.128 230.051C447.096 229.756 446.506 230.346 446.948 231.231Z" fill="black" />
|
||||||
|
<path d="M416.27 239.491C414.5 244.063 419.663 246.571 422.907 242.588C424.382 240.818 424.087 240.081 421.727 238.901C417.893 236.836 417.155 236.836 416.27 239.491Z" fill="black" />
|
||||||
|
<path d="M437.361 239.638C436.034 241.113 437.656 243.178 440.164 243.178C441.344 243.178 442.229 242.146 442.229 240.966C442.229 238.754 439.131 237.869 437.361 239.638Z" fill="black" />
|
||||||
|
<path d="M350.342 244.063C348.72 245.685 350.785 247.603 352.997 246.718C354.177 246.128 354.767 245.243 354.324 244.505C353.44 242.883 351.67 242.736 350.342 244.063Z" fill="black" />
|
||||||
|
<path d="M405.651 251.585C405.946 254.535 406.831 255.715 409.043 255.715C413.616 255.715 413.173 248.488 408.601 247.898C405.651 247.455 405.209 247.898 405.651 251.585Z" fill="black" />
|
||||||
|
<path d="M425.267 249.078C423.792 251.585 428.217 255.567 431.167 254.387C432.494 253.945 433.379 252.323 433.084 250.995C432.494 247.898 426.742 246.571 425.267 249.078Z" fill="black" />
|
||||||
|
<path d="M335.446 253.208C335.151 253.798 335.298 255.272 336.036 256.452C337.068 258.075 338.101 258.222 340.018 257.042C344 254.535 344 252.028 339.87 252.028C337.953 252.028 335.888 252.618 335.446 253.208Z" fill="black" />
|
||||||
|
<path d="M415.975 260.434C416.713 264.122 420.105 264.564 420.105 261.024C420.105 259.255 419.22 257.927 417.745 257.927C416.418 257.927 415.68 258.96 415.975 260.434Z" fill="black" />
|
||||||
|
<path d="M37.5159 264.122C41.4981 268.989 46.9552 274.151 47.9877 274.151C48.4301 274.151 51.2325 276.068 54.3298 278.576C57.427 280.936 60.9668 283 62.1467 283C63.3266 283 65.0965 284.033 66.129 285.213C67.1614 286.393 69.5212 287.425 71.5861 287.425C73.5035 287.425 77.0432 288.458 79.4031 289.637C82.2054 291.112 87.3675 291.85 94.1521 291.702C100.494 291.555 105.361 292.145 106.689 293.325C108.459 294.8 109.196 294.505 110.523 291.407C111.408 289.342 113.621 287.278 115.391 286.835C119.373 285.803 128.075 277.838 128.075 275.183C128.075 271.201 126.305 271.644 118.045 276.953C108.311 283.443 96.0694 286.688 86.6301 285.36C69.8162 283.148 66.7189 282.558 65.6865 280.641C65.0965 279.461 62.8842 278.576 61.1143 278.576C59.1969 278.576 57.2795 277.543 56.6896 276.363C56.2471 275.183 55.2147 274.151 54.3298 274.151C52.4124 274.151 50.0525 272.381 43.268 265.597C37.6634 260.139 33.0912 258.96 37.5159 264.122Z" fill="black" />
|
||||||
|
<path d="M226.303 265.154C225.713 266.039 225.861 267.957 226.746 269.284C228.073 271.791 228.221 271.791 229.253 269.284C230.433 266.187 227.778 262.647 226.303 265.154Z" fill="black" />
|
||||||
|
<path d="M406.241 268.989C405.061 272.086 406.388 274.151 409.191 274.151C410.666 274.151 411.256 272.971 410.961 270.906C410.371 266.924 407.568 265.892 406.241 268.989Z" fill="black" />
|
||||||
|
<path d="M415.68 280.788C415.68 281.968 417.008 283 418.63 283C420.252 283 421.58 281.968 421.58 280.788C421.58 279.608 420.252 278.576 418.63 278.576C417.008 278.576 415.68 279.608 415.68 280.788Z" fill="black" />
|
||||||
|
<path d="M126.895 287.278C123.65 289.343 125.567 293.767 130.14 294.947C132.647 295.685 134.712 295.98 135.007 295.832C135.154 295.537 134.564 293.177 133.679 290.67C131.762 285.803 130.287 285.065 126.895 287.278Z" fill="black" />
|
||||||
|
<path d="M234.268 290.965C234.268 293.03 233.678 295.095 232.793 295.537C232.055 295.98 231.318 298.045 231.318 300.109C231.318 302.027 230.58 303.649 229.695 303.649C228.81 303.649 225.418 306.009 222.321 308.811C219.076 311.614 215.536 313.973 214.504 313.973C209.637 313.973 210.374 319.431 215.979 324.888C219.076 328.133 220.551 327.838 223.353 323.56C225.418 320.463 225.418 319.431 223.796 317.513C222.173 315.596 222.468 315.448 226.303 316.186C233.825 317.808 234.268 318.251 234.268 322.085C234.268 324.15 235.3 326.215 236.48 326.658C239.577 327.985 243.117 324.593 243.117 320.463C243.117 314.711 249.459 315.743 253.294 322.085C254.916 324.888 257.129 327.248 258.308 327.248C260.963 327.248 263.766 324.15 263.766 321.2C263.766 318.546 266.568 316.923 271.14 316.923C273.205 316.923 274.09 318.251 274.385 321.643C274.68 325.773 275.417 326.51 278.515 326.51C281.612 326.51 282.349 325.773 282.644 321.643C283.087 317.366 282.792 316.923 279.252 316.923C276.155 316.923 275.417 316.333 276.155 314.563C276.597 313.236 277.04 311.909 277.04 311.614C277.04 309.549 281.464 311.614 282.202 313.973C282.939 316.186 284.414 316.923 288.249 316.923C292.379 316.923 293.559 316.186 294.739 313.089C296.656 308.221 302.555 308.664 303.293 313.531C303.735 316.481 303.145 316.923 299.311 316.923C295.771 316.923 294.739 317.513 294.739 319.726C294.739 328.28 299.606 329.755 302.408 322.085C303.883 317.661 304.915 316.923 308.013 317.218C310.372 317.513 312.437 319.136 313.617 321.643C315.977 326.805 320.254 326.953 323.499 321.938C325.416 318.841 326.891 318.251 330.284 318.693C333.676 319.283 334.561 318.841 334.266 317.071C333.823 315.153 331.464 314.563 323.204 314.268C313.027 313.826 312.437 313.678 306.685 307.779C300.786 302.027 300.491 301.879 295.918 303.797C292.821 304.977 285.004 305.714 274.827 305.862C259.783 305.862 258.161 305.567 252.409 301.879C249.164 299.667 244.002 295.537 241.2 292.587C235.3 286.393 234.268 286.245 234.268 290.965ZM242.38 311.761C242.38 315.448 241.79 316.186 238.692 316.186C235.595 316.186 234.858 315.448 234.563 311.319C234.12 306.746 234.268 306.451 238.25 306.894C241.79 307.336 242.38 308.074 242.38 311.761ZM262.291 312.351C264.356 316.333 264.356 316.333 258.603 316.481C254.621 316.628 253.441 316.038 253.441 314.121C253.441 308.811 259.636 307.631 262.291 312.351Z" fill="black" />
|
||||||
|
<path d="M406.831 290.375C406.831 291.997 407.863 293.325 409.043 293.325C410.223 293.325 411.256 291.997 411.256 290.375C411.256 288.753 410.223 287.425 409.043 287.425C407.863 287.425 406.831 288.753 406.831 290.375Z" fill="black" />
|
||||||
|
<path d="M115.243 300.109C114.063 303.059 117.161 306.304 119.225 304.239C120.995 302.469 119.668 297.749 117.603 297.749C116.866 297.749 115.833 298.782 115.243 300.109Z" fill="black" />
|
||||||
|
<path d="M135.449 301.584C135.449 302.912 136.629 304.239 138.104 304.682C139.579 304.977 141.939 307.336 143.414 309.696C144.741 312.056 147.839 315.006 150.198 316.186C152.558 317.366 154.918 319.725 155.36 321.495C156.245 324.15 157.13 324.445 164.21 323.56C170.109 322.823 172.912 323.118 175.272 324.74C178.369 326.952 182.646 326.658 182.646 324.445C182.646 323.708 178.516 322.085 173.502 320.61C159.785 316.481 144.299 307.631 141.349 302.174C139.432 298.634 135.449 298.192 135.449 301.584Z" fill="black" />
|
||||||
|
<path d="M127.042 309.106C123.503 310.434 124.978 313.973 128.96 313.973C131.615 313.973 132.647 313.236 132.205 311.466C131.762 308.811 129.845 307.926 127.042 309.106Z" fill="black" />
|
||||||
|
<path d="M105.951 311.761C105.951 312.941 106.984 313.973 108.164 313.973C109.344 313.973 110.376 312.941 110.376 311.761C110.376 310.581 109.344 309.549 108.164 309.549C106.984 309.549 105.951 310.581 105.951 311.761Z" fill="black" />
|
||||||
|
<path d="M115.686 319.578C115.243 320.463 115.538 321.643 116.423 322.085C118.635 323.56 120.11 321.643 118.193 319.726C117.16 318.693 116.276 318.693 115.686 319.578Z" fill="black" />
|
||||||
|
<path d="M136.924 321.348C136.924 322.97 137.957 324.298 139.137 324.298C140.316 324.298 141.349 322.97 141.349 321.348C141.349 319.725 140.316 318.398 139.137 318.398C137.957 318.398 136.924 319.725 136.924 321.348Z" fill="black" />
|
||||||
|
<path d="M196.215 320.758C192.528 323.265 192.085 325.183 194.888 326.215C197.69 327.247 206.244 322.823 206.244 320.463C206.244 317.661 200.345 317.808 196.215 320.758Z" fill="black" />
|
||||||
|
<path d="M185.006 324.15C184.563 324.888 184.416 326.51 184.858 327.837C185.301 329.165 185.891 331.23 186.333 332.557C187.513 336.392 191.791 331.967 191.201 327.395C190.758 323.56 186.628 321.348 185.006 324.15Z" fill="black" />
|
||||||
|
<path d="M336.331 324.15C336.921 326.068 338.986 326.51 339.133 324.74C339.133 324.15 338.396 323.413 337.511 322.97C336.478 322.675 335.888 323.265 336.331 324.15Z" fill="black" />
|
||||||
|
<path d="M205.507 328.722C204.327 330.64 207.424 334.622 210.079 334.622C211.259 334.622 212.144 333.147 212.144 330.935C212.144 328.28 211.407 327.247 209.342 327.247C207.719 327.247 205.949 327.985 205.507 328.722Z" fill="black" />
|
||||||
|
<path d="M225.418 330.935C225.418 335.212 230.433 336.244 231.908 332.262C233.088 329.165 231.76 327.247 228.221 327.247C226.156 327.247 225.418 328.28 225.418 330.935Z" fill="black" />
|
||||||
|
<path d="M244.002 328.575C242.38 331.23 244.887 334.622 248.427 334.622C251.229 334.622 251.966 333.884 251.966 330.935C251.966 327.985 251.229 327.247 248.427 327.247C246.362 327.247 244.444 327.837 244.002 328.575Z" fill="black" />
|
||||||
|
<path d="M265.24 330.787C265.24 335.654 267.01 336.539 270.845 333.737C275.565 330.492 274.975 327.248 269.665 327.248C265.978 327.248 265.24 327.838 265.24 330.787Z" fill="black" />
|
||||||
|
<path d="M285.447 328.132C284.119 329.607 287.217 334.622 289.429 334.622C291.494 334.622 293.559 329.46 292.084 328.132C291.051 326.952 286.627 327.1 285.447 328.132Z" fill="black" />
|
||||||
|
<path d="M305.358 330.492C305.653 332.41 306.833 333.885 308.013 333.885C309.193 333.885 310.372 332.41 310.667 330.492C311.11 327.985 310.372 327.248 308.013 327.248C305.653 327.248 304.915 327.985 305.358 330.492Z" fill="black" />
|
||||||
|
<path d="M325.121 328.575C324.089 330.197 329.251 334.917 330.579 333.59C331.169 333.147 331.611 331.377 331.611 329.902C331.611 327.1 326.596 326.068 325.121 328.575Z" fill="black" />
|
||||||
|
<path d="M344.885 329.607C344.885 331.525 372.023 331.525 373.793 329.755C375.121 328.575 375.563 328.575 358.602 328.28C349.162 327.985 344.885 328.427 344.885 329.607Z" fill="black" />
|
||||||
|
<path d="M217.159 338.162C215.979 341.259 217.601 345.389 219.519 344.209C220.256 343.766 220.994 341.996 220.994 340.522C220.994 336.834 218.339 335.212 217.159 338.162Z" fill="black" />
|
||||||
|
<path d="M256.981 338.162C255.801 341.259 257.424 345.389 259.341 344.209C260.078 343.766 260.816 341.996 260.816 340.522C260.816 336.834 258.161 335.212 256.981 338.162Z" fill="black" />
|
||||||
|
<path d="M235.005 339.047C234.415 340.079 234.563 341.407 235.448 342.292C237.512 344.356 242.085 342.586 241.347 340.079C240.462 337.719 236.332 336.982 235.005 339.047Z" fill="black" />
|
||||||
|
<path d="M275.565 340.374C275.565 343.619 279.842 345.684 281.907 343.619C284.119 341.407 281.907 337.572 278.515 337.572C276.597 337.572 275.565 338.604 275.565 340.374Z" fill="black" />
|
||||||
|
<path d="M296.213 340.522C296.213 342.144 297.246 343.471 298.426 343.471C299.606 343.471 300.638 342.144 300.638 340.522C300.638 338.899 299.606 337.572 298.426 337.572C297.246 337.572 296.213 338.899 296.213 340.522Z" fill="black" />
|
||||||
|
<path d="M186.481 348.929C184.711 350.698 185.596 353.796 187.808 353.796C188.988 353.796 190.021 352.911 190.021 351.731C190.021 349.224 187.956 347.601 186.481 348.929Z" fill="black" />
|
||||||
|
<path d="M206.982 349.224C205.802 351.436 207.424 353.796 210.079 353.796C211.407 353.796 212.144 352.763 211.849 351.288C211.259 348.339 208.309 347.159 206.982 349.224Z" fill="black" />
|
||||||
|
<path d="M225.861 350.256C224.828 353.058 226.156 354.386 229.105 353.353C231.908 352.173 231.908 349.961 229.105 348.781C227.778 348.339 226.451 348.929 225.861 350.256Z" fill="black" />
|
||||||
|
<path d="M246.509 348.929C242.97 350.256 244.444 353.796 248.427 353.796C251.081 353.796 252.114 353.058 251.671 351.288C251.229 348.634 249.312 347.749 246.509 348.929Z" fill="black" />
|
||||||
|
<path d="M266.273 348.929C264.208 350.846 265.241 353.796 267.895 353.796C271.73 353.796 272.91 352.173 270.993 349.814C269.075 347.749 267.748 347.454 266.273 348.929Z" fill="black" />
|
||||||
|
<path d="M287.364 350.698C287.364 352.468 288.249 353.796 289.724 353.796C291.051 353.796 291.789 352.763 291.494 351.288C290.756 347.601 287.364 347.159 287.364 350.698Z" fill="black" />
|
||||||
|
<path d="M306.095 348.928C305.505 349.371 305.063 350.698 305.063 351.878C305.063 354.09 308.898 354.533 310.077 352.468C311.11 350.846 307.423 347.601 306.095 348.928Z" fill="black" />
|
||||||
|
<path d="M327.186 351.583C327.186 352.763 328.219 353.796 329.399 353.796C330.579 353.796 331.611 352.763 331.611 351.583C331.611 350.404 330.579 349.371 329.399 349.371C328.219 349.371 327.186 350.404 327.186 351.583Z" fill="black" />
|
||||||
|
<path d="M257.129 359.843C255.654 362.055 257.571 363.53 259.488 361.613C260.521 360.58 260.521 359.695 259.636 359.105C258.751 358.663 257.571 358.958 257.129 359.843Z" fill="black" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,96 @@
|
|||||||
|
<svg width="622" height="427" viewBox="0 0 622 427" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M437.786 1.4749C437.786 2.21235 440.884 5.45713 444.866 8.40693C460.5 20.7961 484.246 44.837 495.16 59.291C499.29 64.7481 502.682 69.6153 502.682 70.0578C502.682 71.8276 509.172 79.6446 510.794 79.6446C511.827 79.6446 512.416 76.5473 512.416 71.5327C512.269 63.7157 512.121 63.4207 508.434 63.4207C504.452 63.4207 502.682 61.0609 502.682 56.0462C502.682 54.4238 501.06 52.8014 498.257 51.769C495.898 51.0315 493.833 49.7041 493.833 48.9667C493.833 45.8694 454.6 8.8494 451.208 8.8494C450.323 8.8494 449.586 8.25944 449.586 7.52199C449.586 6.19458 440.589 0 438.671 0C438.229 0 437.786 0.58996 437.786 1.4749Z" fill="black" />
|
||||||
|
<path d="M520.676 76.5472C520.971 77.5797 521.708 78.3171 522.298 78.3171C524.068 78.1696 523.626 76.1047 521.856 75.3673C520.823 75.0723 520.234 75.6623 520.676 76.5472Z" fill="black" />
|
||||||
|
<path d="M529.82 85.6917C528.345 88.199 531.738 91.0014 533.803 88.9365C535.573 87.1666 534.393 84.0693 532.18 84.0693C531.443 84.0693 530.41 84.8068 529.82 85.6917Z" fill="black" />
|
||||||
|
<path d="M389.852 87.609C361.387 93.3611 337.051 118.729 328.496 151.177C324.072 168.434 325.399 185.247 332.479 199.554C333.658 201.914 334.543 204.716 334.543 205.749C334.543 206.781 335.576 207.961 336.756 208.551C337.936 208.993 338.968 210.321 338.968 211.501C338.968 215.63 353.57 228.167 365.074 233.919L376.578 239.524H396.047C410.206 239.524 416.99 238.934 421.12 237.164C424.217 235.837 429.379 233.772 432.624 232.297C435.869 230.969 441.031 227.725 444.128 224.922L449.733 220.055L451.945 193.359C453.125 178.758 454.01 163.566 453.715 159.732L453.273 152.652L444.423 151.767C439.556 151.177 426.872 150.735 416.253 150.587L396.932 150.44L395.309 161.797C394.277 168.139 393.539 174.038 393.539 175.071C393.539 176.103 397.817 177.135 404.601 177.725C417.285 178.905 420.53 181.56 418.76 188.492C417.875 191.59 415.958 193.064 410.353 195.129C399.881 199.112 389.557 196.752 381.15 188.197C371.858 178.905 369.646 167.844 373.776 151.177C376.136 141.738 393.834 124.039 401.209 123.892C402.684 123.892 406.814 123.154 410.353 122.122C415.515 120.794 419.055 120.794 427.315 122.417C433.657 123.597 438.376 123.892 439.114 123.007C439.851 122.269 440.736 115.485 441.179 107.963C442.064 94.5411 441.916 94.2461 438.229 91.8862C431.444 87.4615 403.126 84.9542 389.852 87.609Z" fill="black" />
|
||||||
|
<path d="M307.995 90.7062C303.571 91.8861 294.869 92.7711 288.822 92.9185C282.774 92.9185 276.875 93.5085 275.695 94.246C273.63 95.5734 271.123 101.325 267.288 114.305C266.108 118.287 264.486 123.596 263.748 126.104C263.011 128.464 261.093 135.101 259.324 140.853C257.701 146.457 255.194 154.422 254.014 158.552C252.686 162.534 251.359 168.138 250.917 170.793C250.474 173.448 249.589 175.955 248.704 176.398C246.787 177.578 241.625 172.711 241.625 169.613C241.625 168.286 240.592 165.926 239.412 164.304C238.232 162.829 237.2 160.174 237.2 158.404C237.2 156.782 236.463 154.717 235.578 153.832C234.693 152.947 233.513 149.997 232.775 147.342C230.71 139.23 226.286 127.284 221.566 117.549C220.386 115.19 219.501 112.387 219.501 111.355C219.501 110.322 217.879 108.11 215.814 106.488C212.717 104.128 211.094 103.833 206.522 104.865C203.572 105.603 197.23 106.193 192.658 106.193C184.989 106.193 168.47 109.437 166.7 111.355C166.257 111.797 166.7 118.582 167.732 126.546C168.617 134.658 170.092 152.652 170.829 166.811C171.714 180.97 172.894 195.719 173.632 199.849C174.517 203.831 175.254 214.893 175.402 224.185C175.549 233.477 175.992 243.063 176.582 245.571C177.614 250.585 178.057 250.585 204.752 248.226L218.764 247.046L219.206 238.934C219.501 234.509 218.764 222.12 217.436 211.648C213.897 180.528 213.159 166.221 215.224 166.959C216.256 167.254 217.584 168.581 218.026 169.761C219.354 172.268 223.631 182.15 226.138 188.05C227.171 190.409 228.793 194.392 229.826 196.899C230.858 199.259 232.185 203.684 232.775 206.486C234.103 212.533 236.61 219.612 240.592 228.167C242.805 233.329 244.132 234.509 247.229 234.509C254.014 234.509 257.996 229.789 259.471 220.055C260.356 213.27 262.126 205.306 265.961 190.999C270.533 173.596 271.713 168.581 272.303 162.681C272.598 159.289 273.778 155.749 274.81 154.864C278.645 151.62 280.267 157.519 281.595 181.708C282.332 194.982 283.512 208.403 284.397 211.648C285.134 214.893 285.872 223.152 285.872 230.084C285.872 246.456 286.314 247.046 296.344 244.981C300.768 244.096 307.995 243.063 312.567 242.621C326.284 241.146 326.579 240.704 325.989 219.465C325.694 208.551 324.662 200.144 323.629 198.374C322.597 196.752 320.974 190.704 319.794 184.805C317.877 173.891 318.32 161.649 321.564 146.015C322.449 141.59 323.334 127.431 323.334 114.6C323.482 86.2815 323.924 86.8715 307.995 90.7062Z" fill="black" />
|
||||||
|
<path d="M475.544 89.6739C474.069 92.0337 471.857 126.104 471.709 147.49C471.562 157.667 470.972 171.236 470.234 177.725C469.349 186.28 469.497 189.967 470.677 191.294C471.709 192.327 478.641 193.507 487.196 194.097L501.945 195.129L504.599 184.215C505.927 178.168 507.107 169.908 507.107 165.631C507.107 161.502 508.139 152.8 509.319 146.458C510.499 140.115 511.531 132.593 511.531 129.644C511.531 126.694 512.416 121.679 513.449 118.582C516.104 111.207 517.579 94.0986 515.956 91.5912C514.334 88.9364 477.019 87.1665 475.544 89.6739Z" fill="black" />
|
||||||
|
<path d="M541.767 97.3434C540.587 99.4083 542.209 101.768 544.864 101.768C546.044 101.768 546.929 100.441 546.929 98.8183C546.929 95.7211 543.389 94.6886 541.767 97.3434Z" fill="black" />
|
||||||
|
<path d="M525.838 102.801C523.478 105.013 525.101 109.585 535.277 130.086C538.522 136.428 546.929 161.797 546.929 165.041C546.929 166.369 547.962 170.056 548.994 173.301C553.861 187.312 553.124 224.332 547.667 238.934C542.357 253.093 526.576 268.874 509.319 277.281C505.632 279.051 502.387 281.116 501.797 281.853C501.355 282.591 499.437 283.181 497.668 283.181C495.75 283.181 493.39 284.066 492.505 285.246C491.62 286.426 488.966 287.606 486.753 288.048C484.541 288.491 479.526 289.965 475.397 291.145C471.414 292.473 465.072 294.095 461.385 295.128C457.698 296.013 452.831 297.34 450.323 298.225C447.963 298.962 443.096 300.29 439.556 301.175C433.952 302.502 432.919 303.387 429.527 310.172C427.462 314.449 424.512 320.201 422.743 323.003C421.12 325.805 418.318 330.82 416.695 334.065C414.926 337.31 410.648 344.094 407.256 349.256C403.716 354.419 400.914 358.991 400.914 359.286C400.914 360.318 387.935 377.574 384.838 380.672C383.51 381.999 376.578 389.816 369.204 398.076C361.977 406.335 353.127 414.742 349.735 416.807C345.605 419.167 343.393 421.527 343.393 423.296C343.393 427.131 345.753 426.984 348.85 423.296C350.325 421.674 351.8 420.347 352.39 420.347C353.717 420.347 362.124 413.12 366.106 408.695C367.876 406.777 373.333 401.025 378.496 396.158C386.165 388.784 396.932 376.247 404.601 365.923C405.339 364.89 407.994 361.941 410.501 359.286C412.861 356.778 414.926 353.534 415.221 352.059C415.516 350.584 416.843 347.781 418.17 345.717L420.678 341.882L423.038 345.127C425.545 348.519 427.462 347.781 427.462 343.357C427.462 341.882 426.43 340.702 425.25 340.702C421.71 340.702 422.595 336.572 428.2 327.28C431.739 321.676 434.542 318.578 436.754 318.283C439.261 317.988 440.146 316.661 440.736 312.679C441.326 308.107 442.064 307.517 446.783 306.632C449.881 306.189 453.273 304.714 454.748 303.387C457.993 299.995 460.943 300.29 462.86 303.829C464.482 306.779 469.497 307.664 472.594 305.599C473.627 305.009 474.659 302.945 474.807 300.88C475.249 298.077 476.429 297.045 479.674 296.75C482.034 296.455 484.836 295.275 486.016 294.095C488.818 291.293 496.193 291.44 497.373 294.243C499.29 299.257 507.107 295.57 507.107 289.67C507.107 287.458 508.287 285.983 510.352 285.246C512.122 284.656 515.809 282.001 518.464 279.346C521.118 276.544 524.068 274.331 525.101 274.331C526.133 274.331 528.64 272.562 530.558 270.644C534.982 265.924 537.49 265.925 538.67 270.792C539.407 273.741 540.44 274.479 543.684 274.036C548.847 273.447 550.321 266.367 545.602 264.745C542.062 263.712 541.325 257.518 544.569 255.6C545.602 255.01 547.962 252.208 549.584 249.406C551.206 246.751 554.156 243.801 556.074 242.769C560.203 240.704 561.236 235.689 557.991 233.034C555.189 230.674 555.189 225.66 557.991 221.825C559.171 220.35 560.203 216.81 560.203 214.156C560.203 209.878 560.646 209.436 564.628 209.436C567.43 209.436 569.495 208.551 570.085 207.076C571.56 203.241 568.463 199.849 564.038 200.291C560.498 200.586 560.203 200.291 560.203 195.572C560.203 192.769 559.613 190.115 558.876 189.672C556.664 188.197 556.221 179.938 558.138 177.283C560.941 173.743 560.793 173.153 555.631 167.991C550.469 162.682 550.321 157.814 555.484 155.602C561.826 152.8 557.401 145.868 550.764 148.375C548.847 149.112 548.404 148.227 548.404 144.098C548.404 141.148 547.667 138.346 546.929 137.903C544.569 136.428 539.26 125.219 540.292 124.187C540.882 123.597 541.915 123.007 542.652 123.007C543.389 122.859 544.717 122.712 545.454 122.564C547.814 122.269 547.224 115.042 544.864 115.042C543.684 115.042 542.209 115.632 541.767 116.517C540.145 119.172 535.13 118.14 535.13 115.19C535.13 113.567 535.867 111.797 536.605 111.355C539.26 109.733 538.227 107.963 532.77 104.865C526.871 101.326 527.166 101.473 525.838 102.801Z" fill="black" />
|
||||||
|
<path d="M552.386 107.078C550.469 108.995 551.354 110.618 554.451 110.618C556.074 110.618 557.254 109.88 556.959 108.848C556.221 106.783 553.714 105.75 552.386 107.078Z" fill="black" />
|
||||||
|
<path d="M565.365 116.665C564.775 117.549 564.923 118.877 565.808 119.762C566.693 120.647 567.43 120.352 568.02 118.877C569.053 116.222 566.693 114.305 565.365 116.665Z" fill="black" />
|
||||||
|
<path d="M90.4474 123.744C74.8135 125.956 51.6576 142.475 44.2831 156.487C42.5132 160.027 40.4483 164.009 39.7109 165.189C31.009 181.118 31.8939 216.368 41.3333 233.329C43.8406 237.901 53.28 249.848 54.7549 250.438C55.3448 250.733 57.9996 252.945 60.6545 255.305C65.2266 259.435 69.0614 261.647 83.8104 267.842C96.937 273.446 130.86 270.497 140.004 262.975C141.184 261.942 142.806 261.057 143.396 261.057C145.609 261.057 161.98 247.488 161.98 245.718C161.98 244.538 163.013 242.916 164.192 241.883C167.142 239.524 167.142 216.22 164.192 188.05C163.013 176.693 161.98 162.681 161.98 157.077C161.98 146.752 161.98 146.605 155.786 140.705C148.706 134.068 143.249 130.381 134.989 126.546C128.5 123.597 102.689 121.974 90.4474 123.744ZM116.111 166.664C127.467 172.416 135.432 187.017 135.432 202.209C135.432 212.091 132.335 219.76 125.55 225.807C117.438 233.329 114.488 234.509 104.459 234.509C95.4621 234.509 94.4297 234.214 87.9401 228.609C84.2529 225.365 79.2382 219.465 77.1733 215.63C73.7811 209.731 73.1911 206.928 73.0436 196.899C72.8961 186.722 73.4861 184.362 76.7309 179.2C80.8606 172.563 83.2204 170.498 90.1525 166.516C96.642 162.829 108.884 162.976 116.111 166.664Z" fill="black" />
|
||||||
|
<path d="M551.354 129.791C551.354 131.709 552.386 132.741 554.009 132.741C557.843 132.741 559.023 131.119 557.106 128.759C554.599 125.809 551.354 126.399 551.354 129.791Z" fill="black" />
|
||||||
|
<path d="M573.477 130.529C573.477 131.709 574.51 132.741 575.69 132.741C576.87 132.741 577.902 131.709 577.902 130.529C577.902 129.349 576.87 128.316 575.69 128.316C574.51 128.316 573.477 129.349 573.477 130.529Z" fill="black" />
|
||||||
|
<path d="M562.563 138.493C561.088 140.853 563.301 144.54 566.25 144.54C568.315 144.54 569.053 143.508 569.053 140.853C569.053 138.198 568.315 137.166 566.25 137.166C564.628 137.166 563.006 137.756 562.563 138.493Z" fill="black" />
|
||||||
|
<path d="M574.51 149.997C572.592 151.767 573.477 154.864 575.69 154.864C576.87 154.864 577.902 153.537 577.902 151.915C577.902 148.965 576.28 148.08 574.51 149.997Z" fill="black" />
|
||||||
|
<path d="M561.973 162.534C562.563 166.664 568.168 167.991 569.643 164.451C570.97 160.912 569.495 159.289 565.218 159.289C562.121 159.289 561.531 159.879 561.973 162.534Z" fill="black" />
|
||||||
|
<path d="M582.917 160.912C582.474 161.797 582.769 163.271 583.507 164.009C585.424 165.926 590.144 162.829 588.816 160.617C587.784 158.847 584.244 158.994 582.917 160.912Z" fill="black" />
|
||||||
|
<path d="M574.51 170.646C572.592 172.416 573.477 176.988 575.542 176.988C578.344 176.988 579.672 174.923 578.492 171.973C577.46 169.466 576.132 169.024 574.51 170.646Z" fill="black" />
|
||||||
|
<path d="M562.12 180.97C559.023 182.15 559.908 185.837 563.595 187.165C567.43 188.787 571.412 185.837 570.085 182.298C569.2 179.938 565.808 179.348 562.12 180.97Z" fill="black" />
|
||||||
|
<path d="M582.327 183.625C582.327 185.395 583.359 185.837 586.014 185.542C588.079 185.247 589.849 184.363 589.849 183.625C589.849 182.74 588.079 182.003 586.014 181.708C583.359 181.413 582.327 181.855 582.327 183.625Z" fill="black" />
|
||||||
|
<path d="M573.477 194.687C573.477 198.374 574.067 199.259 576.132 198.817C577.755 198.522 578.64 197.047 578.64 194.687C578.64 192.327 577.755 190.852 576.132 190.557C574.067 190.115 573.477 191 573.477 194.687Z" fill="black" />
|
||||||
|
<path d="M596.191 192.327C595.601 193.654 595.601 195.277 596.191 195.719C596.633 196.309 597.961 195.867 598.846 194.982C600.173 193.654 600.173 192.769 598.846 191.442C597.518 190.115 596.928 190.41 596.191 192.327Z" fill="black" />
|
||||||
|
<path d="M15.2275 197.489C-0.406457 227.872 -2.61881 238.196 2.39585 256.927C5.49314 268.874 18.9147 281.853 37.646 291.145C55.6398 299.995 57.7046 300.879 81.598 307.517C86.4652 308.991 91.1849 310.614 91.9223 311.204C92.8072 311.794 98.1169 312.826 103.722 313.269C109.474 313.859 118.028 315.333 122.895 316.366C127.762 317.546 134.399 318.726 137.644 318.873C142.806 319.316 143.544 319.758 143.986 323.298C144.429 327.28 144.281 327.428 137.497 327.428C132.63 327.428 129.975 326.69 128.795 324.92C127.467 323.15 125.108 322.561 120.535 322.708C103.132 323.593 95.757 327.428 111.686 327.428C123.19 327.428 129.827 328.903 128.942 331.115C128.647 332.147 129.09 333.77 129.975 334.655C132.335 337.015 139.414 336.72 140.447 334.065C140.889 332.737 143.249 331.852 146.494 331.852C151.066 331.852 151.656 332.295 151.656 335.54C151.656 339.817 153.131 340.112 159.178 336.867C164.93 333.917 164.045 331.557 156.67 329.935C149.591 328.46 148.116 327.133 149.443 323.15C150.623 319.463 157.703 319.021 163.455 322.118C165.815 323.593 169.65 323.888 174.959 323.445C179.974 322.855 183.956 323.298 186.463 324.478C189.266 326.1 190.446 326.1 191.773 324.773C193.985 322.561 203.572 322.561 208.439 324.773C211.537 326.1 213.012 326.1 215.224 324.773C218.764 322.561 225.843 322.561 228.056 324.773C229.383 326.1 231.448 326.1 237.052 324.625C243.542 323.003 245.017 323.003 248.852 325.363C252.834 327.723 253.424 327.723 258.291 325.51C264.781 322.413 265.961 322.413 271.123 325.51C275.105 327.723 275.547 327.723 279.087 325.51C283.659 322.413 290.739 322.266 293.246 325.215C294.279 326.395 295.901 327.428 296.933 327.428C297.966 327.428 299.588 326.395 300.621 325.215C301.653 324.035 304.603 323.003 307.258 323.003C309.913 323.003 312.862 324.035 313.895 325.215C316.255 328.018 318.172 328.018 321.269 325.215C324.809 321.971 333.069 321.823 334.691 324.92C336.756 328.608 341.77 328.018 345.163 323.74C346.785 321.676 349.587 320.053 351.505 320.053C353.422 320.053 355.782 321.528 357.257 323.74C360.059 328.018 365.516 328.755 365.516 324.773C365.516 320.938 368.466 318.578 373.333 318.578C376.578 318.578 377.463 317.988 377.021 315.923C376.578 313.711 374.808 313.416 360.354 313.859C351.357 314.154 339.853 315.039 334.543 315.923C318.909 318.283 224.368 319.168 205.49 317.103C196.64 316.071 182.629 314.744 174.517 314.154C157.26 312.974 138.529 310.466 125.108 307.517C119.798 306.484 109.916 304.419 102.984 302.944C96.052 301.617 88.6775 299.552 86.4652 298.372C84.2528 297.34 80.8606 296.455 78.9432 296.455C77.0258 296.455 74.2235 295.422 72.6011 294.242C71.1262 293.062 68.4714 292.03 66.9965 292.03C62.4243 292.03 34.8437 277.871 28.2066 272.119C24.9618 269.317 19.6522 263.122 16.5549 258.55L10.9503 249.995V234.361C10.9503 222.12 11.3927 217.99 13.4576 214.893C14.785 212.828 15.9649 210.173 15.9649 209.288C15.9649 208.256 16.8499 206.338 18.0298 205.158C19.0622 203.831 20.3896 201.029 20.9796 198.669C21.717 195.424 21.4221 194.687 19.3572 194.687C17.8823 194.687 15.9649 196.014 15.2275 197.489Z" fill="black" />
|
||||||
|
<path d="M583.359 201.619C582.769 202.061 582.327 203.831 582.327 205.306C582.327 208.256 588.816 209.141 590.439 206.486C592.504 203.241 586.014 198.816 583.359 201.619Z" fill="black" />
|
||||||
|
<path d="M604.745 205.306C605.778 208.108 610.35 208.551 610.35 205.896C610.35 204.421 609.022 203.536 607.253 203.536C605.483 203.536 604.45 204.274 604.745 205.306Z" fill="black" />
|
||||||
|
<path d="M473.627 215.04C462.86 220.94 459.615 232.592 465.957 242.769C469.349 248.373 475.249 250.143 485.278 248.816C499.437 246.898 503.862 227.43 492.358 218.138C485.573 212.681 479.674 211.648 473.627 215.04Z" fill="black" />
|
||||||
|
<path d="M572.888 213.566C571.56 215.63 573.772 221.235 575.69 221.235C578.05 221.235 579.967 216.073 578.64 213.861C577.607 212.091 573.92 211.943 572.888 213.566Z" fill="black" />
|
||||||
|
<path d="M595.601 215.335C595.601 216.958 596.633 218.285 597.813 218.285C598.993 218.285 600.026 216.958 600.026 215.335C600.026 213.713 598.993 212.386 597.813 212.386C596.633 212.386 595.601 213.713 595.601 215.335Z" fill="black" />
|
||||||
|
<path d="M618.462 214.008C616.987 216.22 618.904 217.695 620.822 215.778C621.854 214.745 621.854 213.861 620.969 213.271C620.084 212.828 618.904 213.123 618.462 214.008Z" fill="black" />
|
||||||
|
<path d="M561.973 222.563C558.433 224.775 560.498 230.822 564.775 231.264C569.79 231.854 572.74 228.315 570.527 224.332C568.905 221.088 565.365 220.35 561.973 222.563Z" fill="black" />
|
||||||
|
<path d="M583.359 223.742C582.769 224.185 582.327 225.955 582.327 227.43C582.327 229.347 583.507 230.084 586.751 230.084C591.324 230.084 592.504 227.577 589.406 224.48C587.489 222.562 584.834 222.12 583.359 223.742Z" fill="black" />
|
||||||
|
<path d="M605.04 225.807C603.713 228.167 606.81 230.674 609.612 229.494C611.677 228.757 611.825 228.167 610.35 226.397C608.138 223.742 606.515 223.595 605.04 225.807Z" fill="black" />
|
||||||
|
<path d="M572.74 235.984C572.297 236.722 572.592 238.786 573.477 240.409C574.805 242.916 575.542 243.211 577.312 241.736C580.409 239.229 579.967 234.509 576.575 234.509C574.952 234.509 573.182 235.099 572.74 235.984Z" fill="black" />
|
||||||
|
<path d="M595.601 237.607C595.601 241.146 598.993 240.704 599.731 237.017C600.026 235.542 599.288 234.509 597.961 234.509C596.486 234.509 595.601 235.837 595.601 237.607Z" fill="black" />
|
||||||
|
<path d="M562.416 245.718C559.466 248.816 560.203 251.323 564.481 251.913C568.905 252.65 571.118 250.291 569.79 246.456C568.61 242.769 565.66 242.474 562.416 245.718Z" fill="black" />
|
||||||
|
<path d="M582.917 247.046C581.737 249.848 583.359 252.208 586.604 252.208C590.291 252.208 591.324 250.733 589.554 247.636C587.784 244.244 584.097 243.949 582.917 247.046Z" fill="black" />
|
||||||
|
<path d="M605.925 249.258C605.925 249.996 606.957 250.733 608.137 250.733C609.317 250.733 610.35 249.996 610.35 249.258C610.35 248.373 609.317 247.783 608.137 247.783C606.957 247.783 605.925 248.373 605.925 249.258Z" fill="black" />
|
||||||
|
<path d="M550.617 256.78C549.142 261.057 552.534 264.155 556.959 262.385C560.498 261.057 561.236 258.255 558.433 255.453C555.631 252.65 551.649 253.388 550.617 256.78Z" fill="black" />
|
||||||
|
<path d="M573.035 257.223C571.855 260.025 575.247 262.68 577.755 261.057C580.262 259.582 578.787 255.158 575.837 255.158C574.805 255.158 573.477 256.043 573.035 257.223Z" fill="black" />
|
||||||
|
<path d="M595.601 258.845C595.601 260.025 596.633 261.057 597.813 261.057C598.993 261.057 600.026 260.025 600.026 258.845C600.026 257.665 598.993 256.633 597.813 256.633C596.633 256.633 595.601 257.665 595.601 258.845Z" fill="black" />
|
||||||
|
<path d="M618.019 257.96C618.314 258.992 619.052 259.73 619.642 259.73C621.412 259.582 620.969 257.517 619.199 256.78C618.167 256.485 617.577 257.075 618.019 257.96Z" fill="black" />
|
||||||
|
<path d="M561.973 269.464C562.563 274.626 568.315 276.101 569.79 271.529C571.117 267.399 569.643 265.482 565.218 265.482C561.973 265.482 561.531 266.072 561.973 269.464Z" fill="black" />
|
||||||
|
<path d="M582.917 267.694C581.737 270.497 583.359 272.857 586.752 272.857C589.554 272.857 591.176 270.349 590.144 267.252C589.259 264.745 583.949 265.04 582.917 267.694Z" fill="black" />
|
||||||
|
<path d="M605.188 266.957C604.155 268.579 605.483 271.382 607.4 271.382C607.99 271.382 608.875 270.497 609.317 269.317C610.202 266.957 606.515 264.892 605.188 266.957Z" fill="black" />
|
||||||
|
<path d="M528.935 277.871C527.903 282.886 529.673 284.951 533.803 284.361C536.162 284.066 537.49 282.738 537.785 280.379C538.67 274.036 530.115 271.677 528.935 277.871Z" fill="black" />
|
||||||
|
<path d="M551.944 278.019C550.911 280.526 552.239 284.656 554.009 284.656C554.599 284.656 556.368 283.771 557.843 282.738C560.351 280.821 560.351 280.673 557.991 278.314C555.041 275.216 552.976 275.069 551.944 278.019Z" fill="black" />
|
||||||
|
<path d="M573.477 280.231C573.477 281.853 574.51 283.181 575.69 283.181C576.87 283.181 577.902 281.853 577.902 280.231C577.902 278.609 576.87 277.281 575.69 277.281C574.51 277.281 573.477 278.609 573.477 280.231Z" fill="black" />
|
||||||
|
<path d="M595.601 281.116C595.601 282.443 596.338 283.181 597.371 282.886C600.026 282.001 600.468 278.756 597.961 278.756C596.633 278.756 595.601 279.789 595.601 281.116Z" fill="black" />
|
||||||
|
<path d="M517.726 290.703C518.316 295.128 522.888 297.93 524.806 294.98C527.166 291.293 525.838 288.343 521.413 287.901C517.726 287.458 517.284 287.901 517.726 290.703Z" fill="black" />
|
||||||
|
<path d="M539.997 289.965C538.965 292.768 542.947 297.192 545.307 295.865C546.192 295.275 546.929 293.21 546.929 291.145C546.929 288.638 546.192 287.606 543.979 287.606C542.357 287.606 540.587 288.638 539.997 289.965Z" fill="black" />
|
||||||
|
<path d="M563.448 290.85C564.185 295.865 569.053 296.307 569.053 291.44C569.053 288.49 568.315 287.605 565.955 287.605C563.743 287.605 563.006 288.343 563.448 290.85Z" fill="black" />
|
||||||
|
<path d="M14.785 293.8C15.2275 294.833 16.7024 296.013 18.0298 296.16C19.7996 296.603 20.5371 296.013 20.0946 294.685C19.6522 293.653 18.1772 292.473 16.8498 292.325C15.08 291.883 14.3425 292.473 14.785 293.8Z" fill="black" />
|
||||||
|
<path d="M486.753 298.225C482.919 302.059 485.573 306.779 491.621 306.779C494.423 306.779 495.898 301.175 493.833 298.667C491.621 296.012 489.113 295.865 486.753 298.225Z" fill="black" />
|
||||||
|
<path d="M508.877 297.782C507.697 298.372 507.107 300.585 507.402 302.502C507.697 305.157 508.729 306.042 511.532 306.042C514.334 306.042 515.366 305.157 515.661 302.502C516.104 299.552 514.039 296.455 511.532 296.455C511.089 296.455 509.909 297.045 508.877 297.782Z" fill="black" />
|
||||||
|
<path d="M529.23 301.47C529.23 306.042 531.295 307.369 535.13 305.304C539.407 302.944 538.227 298.815 533.213 298.225C529.82 297.782 529.23 298.225 529.23 301.47Z" fill="black" />
|
||||||
|
<path d="M552.239 298.962C551.796 299.405 551.354 301.175 551.354 302.649C551.354 304.714 552.239 305.452 554.599 305.009C556.369 304.714 557.991 303.829 558.286 302.797C559.023 300.88 553.714 297.487 552.239 298.962Z" fill="black" />
|
||||||
|
<path d="M574.362 300.437C572.592 302.207 573.477 305.304 575.69 305.304C576.87 305.304 577.902 304.419 577.902 303.24C577.902 300.732 575.837 299.11 574.362 300.437Z" fill="black" />
|
||||||
|
<path d="M29.2391 302.354C29.2391 304.419 33.0738 305.747 35.2862 304.567C36.4661 303.682 36.3186 303.092 34.9912 302.207C32.1889 300.437 29.2391 300.585 29.2391 302.354Z" fill="black" />
|
||||||
|
<path d="M38.0884 306.779C38.0884 308.402 45.168 310.466 46.3479 309.287C46.9378 308.844 46.7904 307.812 46.2004 306.927C45.0205 304.862 38.0884 304.862 38.0884 306.779Z" fill="black" />
|
||||||
|
<path d="M48.7078 311.499C49.4452 313.711 58.7371 315.039 58.7371 312.974C58.7371 311.204 55.1973 309.729 51.0676 309.729C49.4452 309.729 48.4128 310.466 48.7078 311.499Z" fill="black" />
|
||||||
|
<path d="M454.01 313.416C454.01 315.481 455.043 317.546 456.223 317.988C459.173 319.168 462.86 316.071 462.86 312.531C462.86 310.467 461.827 309.729 458.435 309.729C454.748 309.729 454.01 310.319 454.01 313.416Z" fill="black" />
|
||||||
|
<path d="M476.134 313.269C476.134 318.283 478.789 319.463 482.329 315.924C485.868 312.384 484.688 309.729 479.674 309.729C476.871 309.729 476.134 310.466 476.134 313.269Z" fill="black" />
|
||||||
|
<path d="M498.257 313.269C498.257 317.989 500.322 319.168 503.272 316.219C506.812 312.826 506.222 309.729 501.945 309.729C498.995 309.729 498.257 310.467 498.257 313.269Z" fill="black" />
|
||||||
|
<path d="M519.791 311.056C518.464 313.121 520.528 317.251 522.446 316.661C525.101 315.776 525.396 309.729 522.741 309.729C521.561 309.729 520.233 310.319 519.791 311.056Z" fill="black" />
|
||||||
|
<path d="M541.62 311.351C541.177 312.236 541.325 313.564 542.209 314.449C544.127 316.366 546.929 314.744 546.929 311.794C546.929 309.434 543.094 308.992 541.62 311.351Z" fill="black" />
|
||||||
|
<path d="M565.365 311.351C563.891 313.564 565.808 315.039 567.725 313.121C568.758 312.089 568.758 311.204 567.873 310.614C566.988 310.171 565.808 310.466 565.365 311.351Z" fill="black" />
|
||||||
|
<path d="M64.1942 315.039C62.2768 316.956 64.9316 318.578 69.9463 318.578C73.4861 318.578 74.961 317.989 74.666 316.661C73.9285 314.744 65.8166 313.416 64.1942 315.039Z" fill="black" />
|
||||||
|
<path d="M80.1231 319.906C78.7957 322.266 82.3355 323.298 87.9401 322.266C93.1022 321.086 93.2497 321.086 89.71 320.053C84.4003 318.431 81.1556 318.431 80.1231 319.906Z" fill="black" />
|
||||||
|
<path d="M445.456 320.348C443.096 322.708 443.244 325.51 445.751 326.395C448.258 327.428 449.586 326.248 449.586 322.856C449.586 319.463 447.521 318.284 445.456 320.348Z" fill="black" />
|
||||||
|
<path d="M466.694 322.561C465.662 325.068 468.464 327.87 470.529 326.543C471.119 326.1 471.709 324.478 471.709 322.856C471.709 319.463 468.022 319.168 466.694 322.561Z" fill="black" />
|
||||||
|
<path d="M490.145 321.971C489.555 323.446 489.408 325.068 489.998 325.51C491.473 326.985 492.653 324.921 491.915 321.971C491.325 319.463 491.325 319.463 490.145 321.971Z" fill="black" />
|
||||||
|
<path d="M261.978 330.525C257.111 333.327 259.618 337.752 266.108 337.752C267.73 337.752 268.173 336.572 267.878 333.77C267.435 329.345 265.666 328.313 261.978 330.525Z" fill="black" />
|
||||||
|
<path d="M304.603 329.935C302.981 331.41 303.423 337.457 305.193 338.637C306.078 339.08 308.29 338.195 310.06 336.425C313.157 333.18 313.157 333.18 310.355 330.968C307.11 328.608 305.93 328.46 304.603 329.935Z" fill="black" />
|
||||||
|
<path d="M325.841 331.705C324.809 333.622 324.957 335.097 326.284 337.015C327.759 339.079 328.644 339.227 331.299 337.9C334.986 335.982 335.428 333.327 332.773 330.673C330.266 328.165 327.464 328.46 325.841 331.705Z" fill="black" />
|
||||||
|
<path d="M347.965 331.557C345.9 335.54 348.26 338.342 352.832 337.162C357.404 335.982 357.847 332.59 353.865 330.378C350.03 328.46 349.587 328.46 347.965 331.557Z" fill="black" />
|
||||||
|
<path d="M369.204 331.705C368.171 335.245 369.794 339.227 372.301 339.227C373.628 339.227 374.366 337.457 374.366 334.065C374.366 328.46 370.826 326.69 369.204 331.705Z" fill="black" />
|
||||||
|
<path d="M174.222 332.59C171.714 334.507 171.714 334.655 174.222 335.54C177.172 336.72 181.154 335.54 181.154 333.475C181.154 331.115 177.024 330.525 174.222 332.59Z" fill="black" />
|
||||||
|
<path d="M197.968 332.59C196.493 336.13 198.705 337.9 201.95 336.13C204.31 334.95 204.605 334.212 203.13 332.443C200.918 329.788 199 329.788 197.968 332.59Z" fill="black" />
|
||||||
|
<path d="M219.059 332.443C217.731 335.687 220.976 337.9 224.221 336.13C226.433 334.95 226.728 334.212 225.253 332.443C223.041 329.788 220.091 329.788 219.059 332.443Z" fill="black" />
|
||||||
|
<path d="M239.412 332.442C236.758 334.36 236.758 334.507 240.002 336.13C244.28 338.49 248.557 335.687 245.902 332.442C243.837 329.935 242.952 329.935 239.412 332.442Z" fill="black" />
|
||||||
|
<path d="M282.184 331.852C280.857 334.065 284.249 337.752 287.494 337.752C289.559 337.752 290.296 336.72 290.296 334.065C290.296 331.115 289.559 330.378 286.757 330.378C284.692 330.378 282.627 331.115 282.184 331.852Z" fill="black" />
|
||||||
|
<path d="M433.657 332.295C431.444 334.802 432.772 336.277 437.344 336.277C439.999 336.277 440.884 335.54 440.441 333.77C439.851 330.525 435.869 329.64 433.657 332.295Z" fill="black" />
|
||||||
|
<path d="M455.043 331.263C452.683 333.622 454.158 336.277 457.845 336.277C460.647 336.277 461.532 335.54 461.09 333.77C460.647 330.968 456.813 329.493 455.043 331.263Z" fill="black" />
|
||||||
|
<path d="M477.166 331.41C475.102 333.327 476.134 336.277 478.789 336.277C482.624 336.277 483.803 334.655 481.886 332.295C479.969 330.23 478.641 329.935 477.166 331.41Z" fill="black" />
|
||||||
|
<path d="M499.29 331.263C497.225 333.475 498.405 336.277 501.355 336.277C503.419 336.277 504.157 335.54 503.862 333.77C503.42 331.115 500.912 329.788 499.29 331.263Z" fill="black" />
|
||||||
|
<path d="M112.571 333.327C111.981 334.212 114.636 334.802 119.355 334.802C128.352 334.802 128.352 334.065 119.65 332.737C116.111 332.295 113.161 332.442 112.571 333.327Z" fill="black" />
|
||||||
|
<path d="M520.381 334.065C520.381 335.245 521.413 336.277 522.593 336.277C523.773 336.277 524.806 335.245 524.806 334.065C524.806 332.885 523.773 331.853 522.593 331.853C521.413 331.853 520.381 332.885 520.381 334.065Z" fill="black" />
|
||||||
|
<path d="M250.917 343.062C249.737 346.159 252.834 349.256 255.046 347.044C256.964 345.127 255.784 340.702 253.424 340.702C252.539 340.702 251.507 341.734 250.917 343.062Z" fill="black" />
|
||||||
|
<path d="M272.598 344.389C272.598 347.339 273.188 348.224 275.252 347.781C276.58 347.486 277.76 346.012 277.76 344.389C277.76 342.767 276.58 341.292 275.252 340.997C273.188 340.554 272.598 341.439 272.598 344.389Z" fill="black" />
|
||||||
|
<path d="M295.016 344.389C295.311 346.454 296.196 348.224 296.934 348.224C297.818 348.224 298.556 346.454 298.851 344.389C299.146 341.734 298.703 340.702 296.934 340.702C295.164 340.702 294.721 341.734 295.016 344.389Z" fill="black" />
|
||||||
|
<path d="M315.96 342.914C314.927 345.569 316.402 348.076 319.204 348.076C320.384 348.076 321.269 346.601 321.269 344.389C321.269 340.407 317.287 339.227 315.96 342.914Z" fill="black" />
|
||||||
|
<path d="M336.903 342.029C335.428 344.389 337.641 348.076 340.738 348.076C342.803 348.076 343.54 347.191 343.098 344.832C342.655 341.292 338.378 339.522 336.903 342.029Z" fill="black" />
|
||||||
|
<path d="M358.732 342.914C358.142 344.242 358.437 346.011 359.322 346.896C361.534 349.109 365.811 347.044 365.221 344.094C364.631 340.702 359.912 339.817 358.732 342.914Z" fill="black" />
|
||||||
|
<path d="M445.751 342.767C445.161 344.094 445.161 345.717 445.751 346.159C446.193 346.749 447.521 346.307 448.406 345.422C449.733 344.094 449.733 343.209 448.406 341.882C447.078 340.554 446.488 340.849 445.751 342.767Z" fill="black" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 30 KiB |
Reference in New Issue
Block a user