diff --git a/components/editor/ai/chat-image-preview.tsx b/components/editor/ai/chat-image-preview.tsx index 64621aac..bbec2856 100644 --- a/components/editor/ai/chat-image-preview.tsx +++ b/components/editor/ai/chat-image-preview.tsx @@ -18,13 +18,13 @@ export function ChatImagePreview({ name, src, className, alt, ...props }: ChatIm return ( -
+
{alt +
); @@ -160,10 +160,7 @@ function UserMessage({ return (
{images.map((image, idx) => ( -
+
+
); diff --git a/hooks/use-image-upload-reducer.ts b/hooks/use-image-upload-reducer.ts index e7105256..5a069bb2 100644 --- a/hooks/use-image-upload-reducer.ts +++ b/hooks/use-image-upload-reducer.ts @@ -23,6 +23,9 @@ export const imageUploadReducer: Reducer i !== action.payload.index); } + case "REMOVE_BY_URL": { + return state.filter((image) => image.url !== action.payload.url); + } case "CLEAR": { return []; } @@ -40,7 +43,12 @@ export const createSyncedImageUploadReducer = ( return (state, action) => { const newState = imageUploadReducer(state, action); // Only sync user actions, not initialization - if (action.type === "UPDATE_URL" || action.type === "REMOVE" || action.type === "CLEAR") { + if ( + action.type === "UPDATE_URL" || + action.type === "REMOVE" || + action.type === "REMOVE_BY_URL" || + action.type === "CLEAR" + ) { setImagesDraft(newState.filter((img) => !img.loading).map(({ url }) => ({ url }))); } // Note: INITIALIZE intentionally doesn't sync back to avoid circular updates diff --git a/hooks/use-image-upload.ts b/hooks/use-image-upload.ts index bbc77cf1..a789f03f 100644 --- a/hooks/use-image-upload.ts +++ b/hooks/use-image-upload.ts @@ -1,5 +1,11 @@ import { useToast } from "@/components/ui/use-toast"; +import { MAX_SVG_FILE_SIZE } from "@/lib/constants"; import { PromptImage } from "@/types/ai"; +import { + ALLOWED_IMAGE_TYPES, + optimizeSvgContent, + validateSvgContent, +} from "@/utils/ai/image-upload"; import { useRef } from "react"; export type PromptImageWithLoading = PromptImage & { loading: boolean }; @@ -7,6 +13,7 @@ export type PromptImageWithLoading = PromptImage & { loading: boolean }; export type ImageUploadAction = | { type: "ADD"; payload: { url: string; file: File }[] } | { type: "REMOVE"; payload: { index: number } } + | { type: "REMOVE_BY_URL"; payload: { url: string } } | { type: "CLEAR" } | { type: "UPDATE_URL"; payload: { tempUrl: string; finalUrl: string } } | { type: "INITIALIZE"; payload: { url: string }[] }; @@ -38,7 +45,14 @@ export function useImageUpload({ maxFiles, maxFileSize, images, dispatch }: UseI } const validFiles = fileArray.filter((file) => { - if (!file.type.startsWith("image/")) return false; + if (!ALLOWED_IMAGE_TYPES.includes(file.type)) { + toast({ + title: "Unsupported file type", + description: `"${file.name}" is not supported. Please use JPG, PNG, WebP, or SVG files.`, + }); + return false; + } + if (file.size > maxFileSize) { toast({ title: "File too large", @@ -60,12 +74,71 @@ export function useImageUpload({ maxFiles, maxFileSize, images, dispatch }: UseI filesWithTempUrls.forEach(({ url: tempUrl, file }) => { const reader = new FileReader(); - reader.onload = (e) => { - const finalUrl = e.target?.result as string; + + const handleSuccess = (result: string) => { + let finalUrl: string; + + if (file.type === "image/svg+xml") { + try { + const isValidSvg = validateSvgContent(result); + if (!isValidSvg) { + toast({ + title: "Potentially unsafe SVG", + description: `"${file.name}" may contain unsafe content but will be processed anyway.`, + }); + } + + const optimizedSvg = optimizeSvgContent(result); + const encodedSvg = encodeURIComponent(optimizedSvg); + + if (encodedSvg.length > MAX_SVG_FILE_SIZE) { + handleError(); + return; + } + + finalUrl = `data:image/svg+xml,${encodedSvg}`; + } catch (error) { + handleError(); + return; + } + } else { + finalUrl = result; + } + dispatch({ type: "UPDATE_URL", payload: { tempUrl, finalUrl } }); URL.revokeObjectURL(tempUrl); }; - reader.readAsDataURL(file); + + const handleError = () => { + toast({ + title: "File read error", + description: `Failed to read "${file.name}". Please try again.`, + }); + + dispatch({ + type: "REMOVE_BY_URL", + payload: { url: tempUrl }, + }); + URL.revokeObjectURL(tempUrl); + }; + + reader.onload = (e) => { + const result = e.target?.result; + if (!result || typeof result !== "string") { + handleError(); + return; + } + handleSuccess(result); + }; + + reader.onerror = handleError; + reader.onabort = handleError; + + if (file.type === "image/svg+xml") { + reader.readAsText(file); + } else { + reader.readAsDataURL(file); + } }); }; diff --git a/lib/constants.ts b/lib/constants.ts index 320d7b72..ae218a5b 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -6,5 +6,6 @@ export const AI_REQUEST_FREE_TIER_LIMIT = 5; export const MAX_IMAGE_FILES = 3; export const MAX_IMAGE_FILE_SIZE = 5 * 1024 * 1024; // 5MB +export const MAX_SVG_FILE_SIZE = 1 * 1024 * 1024; // 1MB export const MAX_FREE_THEMES = 10; diff --git a/utils/ai/generate-theme.tsx b/utils/ai/generate-theme.tsx index 8c43fd3f..ec66aef3 100644 --- a/utils/ai/generate-theme.tsx +++ b/utils/ai/generate-theme.tsx @@ -5,12 +5,13 @@ import { z } from "zod"; export const SYSTEM_PROMPT = `# Role You are tweakcn, an expert shadcn/ui theme generator. - # Image Analysis Instructions (when image provided) - - If one or more images are provided (with or without a text prompt), always analyze the image(s) and extract dominant color tokens, mood, border radius, and aesthetic to create a shadcn/ui theme based on them. - - If both images and a text prompt are provided, use the prompt as additional guidance. - - If only a text prompt is provided (no images), generate the theme based on the prompt. - - Consider color harmony, contrast, and visual hierarchy + # Image & SVG Analysis Instructions (when visual content is provided) + - If one or more images are provided (with or without a text prompt), always analyze the image(s) and extract dominant color tokens, mood, border radius, and shadows to create a shadcn/ui theme based on them + - If SVG markup is provided, analyze the SVG code to extract colors, styles, and visual elements for theme generation + - **Always match the colors,border radius and shadows of the source image(s) or SVG elements** as closely as possible + - If both visual content and a text prompt are provided, use the prompt as additional guidance - Translate visual elements into appropriate theme tokens + - If only a text prompt is provided (no visual content), generate the theme based on the prompt # Token Groups - **Brand**: primary, secondary, accent, ring diff --git a/utils/ai/image-upload.ts b/utils/ai/image-upload.ts new file mode 100644 index 00000000..a8621ef3 --- /dev/null +++ b/utils/ai/image-upload.ts @@ -0,0 +1,40 @@ +export const ALLOWED_IMAGE_TYPES = [ + "image/jpeg", + "image/jpg", + "image/png", + "image/webp", + "image/svg+xml", +]; + +export function validateSvgContent(svgText: string): boolean { + try { + const trimmed = svgText.trim(); + if (!trimmed.toLowerCase().includes(" pattern.test(svgText)); + } catch { + return false; + } +} + +export function optimizeSvgContent(svgText: string): string { + try { + return svgText + .replace(//g, "") // Remove comments + .replace(/>\s+<") // Remove unnecessary whitespace + .trim(); + } catch { + return svgText.trim(); + } +} diff --git a/utils/ai/message-converter.ts b/utils/ai/message-converter.ts index 7caeabbe..4015fd69 100644 --- a/utils/ai/message-converter.ts +++ b/utils/ai/message-converter.ts @@ -1,6 +1,6 @@ import { ChatMessage } from "@/types/ai"; import { buildPromptForAPI } from "@/utils/ai/ai-prompt"; -import { CoreMessage, ImagePart, TextPart, UserContent } from "ai"; +import { CoreMessage, TextPart, UserContent } from "ai"; export async function convertChatMessagesToCoreMessages( messages: ChatMessage[] @@ -12,15 +12,36 @@ export async function convertChatMessagesToCoreMessages( const content: UserContent = []; const { promptData } = message; - // Add image parts if (promptData.images && promptData.images.length > 0) { - const imageParts = promptData.images.map( - (image): ImagePart => ({ - type: "image", - image: image.url, - }) - ); - content.push(...imageParts); + promptData.images.forEach((image) => { + if (image.url.startsWith("data:image/svg+xml")) { + try { + const dataUrlPart = image.url.split(",")[1]; + let svgMarkup: string; + + if (image.url.includes("base64")) { + svgMarkup = atob(dataUrlPart); + } else { + svgMarkup = decodeURIComponent(dataUrlPart); + } + + content.push({ + type: "text", + text: `Here is an SVG image for analysis:\n\`\`\`svg\n${svgMarkup}\n\`\`\``, + }); + } catch (error) { + content.push({ + type: "image", + image: image.url, + }); + } + } else { + content.push({ + type: "image", + image: image.url, + }); + } + }); } // Add text part