From 9ec553499ac2ba580fd09701a165914ba9cfa071 Mon Sep 17 00:00:00 2001 From: Younggun Park Date: Sun, 15 Feb 2026 01:24:48 +0900 Subject: [PATCH 1/3] feat: Implement StylePreset-aware asset generation with multi-layout OG images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create lib/assets/style-resolver.ts: slug-based mapping layer translating preset free-text fields (og_layout, og_background, og_typography, shadow_style, color_mode) into concrete satori/sharp styles - Enhance lib/assets/og-image.tsx: 4 layout variants (centered, left-aligned, centered-large-text, grid-balanced), multi-font loading from Google Fonts CDN (Inter, Geist, Plus Jakarta Sans, Nunito), gradient/dark backgrounds, glassmorphism simulation - Extend lib/utils/image.ts: SVG icons with and filters for preset-aware styling, corner_radius masking for AI-generated icons - Add color utilities: darkenHex, lightenHex, hexToRgba for HSL-based color manipulation - Update lib/assets/splash-screen.ts and app-icons.ts: support gradient backgrounds and preset styling - Wire brandProfile through favicon, app-icons, and pipeline for complete preset integration When users click "다시 생성", each of 8 style presets (Notion Minimal, Airbnb 3D, Stripe Gradient, Linear Dark, Vercel Sharp, Glassmorphism, Duolingo Playful, Figma Clean) now produces visually distinct OG images, favicons, app icons, and splash screens. Co-Authored-By: Claude Opus 4.6 --- app/api/ai/icons/route.ts | 1 + app/globals.css | 20 +- components/wizard/icon-ai-tab.tsx | 83 ++++++- components/wizard/step-4-icon.tsx | 7 +- components/wizard/wizard-shell.tsx | 6 +- lib/ai/fal.ts | 9 +- lib/assets/app-icons.ts | 35 ++- lib/assets/favicon.ts | 14 +- lib/assets/og-image.tsx | 341 ++++++++++++++++++++++++--- lib/assets/pipeline.ts | 4 +- lib/assets/splash-screen.ts | 39 +++- lib/assets/style-resolver.ts | 361 +++++++++++++++++++++++++++++ lib/utils/colors.ts | 54 +++++ lib/utils/image.ts | 58 ++++- 14 files changed, 952 insertions(+), 80 deletions(-) create mode 100644 lib/assets/style-resolver.ts diff --git a/app/api/ai/icons/route.ts b/app/api/ai/icons/route.ts index 50cbb33..19495fb 100644 --- a/app/api/ai/icons/route.ts +++ b/app/api/ai/icons/route.ts @@ -37,6 +37,7 @@ export async function POST(request: Request) { const images = await generateIcon({ description: body.description, brandProfile: body.brandProfile, + styleModifier: body.styleModifier, }) await incrementUsage(admin, user.id, 'ai_icons_used_this_month') diff --git a/app/globals.css b/app/globals.css index 77bfb8f..184edf1 100644 --- a/app/globals.css +++ b/app/globals.css @@ -66,8 +66,12 @@ body { /* -- Dark Mode -- */ [data-theme="dark"] { - --color-brand: #fafafa; - --color-brand-foreground: #0a0a0b; + --color-brand: #111114; + --color-brand-foreground: #fafafa; + + --color-accent: #818cf8; + --color-accent-light: #a5b4fc; + --color-accent-dark: #6366f1; --color-surface: #0a0a0b; --color-surface-secondary: #18181b; @@ -84,14 +88,18 @@ body { --shadow-md: 0 2px 4px rgb(0 0 0 / 0.3), 0 4px 12px rgb(0 0 0 / 0.4); --shadow-lg: 0 4px 6px rgb(0 0 0 / 0.3), 0 8px 24px rgb(0 0 0 / 0.4); --shadow-xl: 0 8px 16px rgb(0 0 0 / 0.4), 0 16px 48px rgb(0 0 0 / 0.5); - --shadow-glow: 0 0 20px rgb(99 102 241 / 0.25), 0 0 60px rgb(99 102 241 / 0.15); + --shadow-glow: 0 0 20px rgb(129 140 248 / 0.3), 0 0 60px rgb(129 140 248 / 0.15); } /* 시스템 설정 기반 자동 다크모드 (선택적) */ @media (prefers-color-scheme: dark) { html:not([data-theme="light"]) { - --color-brand: #fafafa; - --color-brand-foreground: #0a0a0b; + --color-brand: #111114; + --color-brand-foreground: #fafafa; + + --color-accent: #818cf8; + --color-accent-light: #a5b4fc; + --color-accent-dark: #6366f1; --color-surface: #0a0a0b; --color-surface-secondary: #18181b; @@ -205,7 +213,7 @@ body { /* -- Gradient Text -- */ .text-gradient { - background: linear-gradient(135deg, var(--color-accent) 0%, #a78bfa 50%, var(--color-accent-light) 100%); + background: linear-gradient(135deg, var(--color-accent) 0%, #c4b5fd 50%, var(--color-accent-light) 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; diff --git a/components/wizard/icon-ai-tab.tsx b/components/wizard/icon-ai-tab.tsx index d3b4c8d..c8943ce 100644 --- a/components/wizard/icon-ai-tab.tsx +++ b/components/wizard/icon-ai-tab.tsx @@ -1,7 +1,7 @@ 'use client' -import { useState } from 'react' -import { Sparkles } from 'lucide-react' +import { useMemo, useState } from 'react' +import { Sparkles, Wand2 } from 'lucide-react' import Image from 'next/image' import { useWizardStore } from '@/store/wizard-store' import { Input } from '@/components/ui/input' @@ -10,12 +10,13 @@ import { Button } from '@/components/ui/button' import { PlanGate } from '@/components/shared/plan-gate' import { UsageMeter } from '@/components/shared/usage-meter' import { cn } from '@/lib/utils/cn' -import type { BrandProfile, Plan, User } from '@/types/database' +import type { BrandProfile, Plan, StylePreset, User } from '@/types/database' interface IconAiTabProps { plan: Plan user: User brandProfile: BrandProfile | null + stylePreset: StylePreset | null } interface GeneratedImage { @@ -23,14 +24,62 @@ interface GeneratedImage { seed: number } -export function IconAiTab({ plan, user, brandProfile }: IconAiTabProps) { - const { icon, setIcon } = useWizardStore() +function buildSuggestions( + projectName: string, + projectDescription: string, + platform: string, + brandProfile: BrandProfile | null, + stylePreset: StylePreset | null, +): string[] { + const suggestions: string[] = [] + + if (stylePreset && projectName) { + suggestions.push(`${stylePreset.name}-style icon for ${projectName}`) + } + + if (brandProfile?.keywords?.length) { + const keyword = brandProfile.keywords[0] + const iconStyle = stylePreset?.icon_style || brandProfile.icon_style + suggestions.push(`${keyword} themed ${iconStyle ? iconStyle + ' ' : ''}icon`) + } + + if (projectName && projectDescription) { + suggestions.push( + `modern ${platform} app icon for ${projectName}, ${projectDescription}`, + ) + } + + if (projectName && !stylePreset) { + suggestions.push(`minimalist icon for ${projectName}`) + } + + if (suggestions.length === 0) { + suggestions.push('minimalist app icon, clean and modern') + } + + return suggestions +} + +export function IconAiTab({ plan, user, brandProfile, stylePreset }: IconAiTabProps) { + const { icon, setIcon, project, platform } = useWizardStore() const [description, setDescription] = useState('') const [isGenerating, setIsGenerating] = useState(false) const [images, setImages] = useState([]) const [error, setError] = useState(null) const [iconsUsed, setIconsUsed] = useState(user.ai_icons_used_this_month) + const suggestions = useMemo( + () => + buildSuggestions( + project.name, + project.description, + platform.platform, + brandProfile, + stylePreset, + ), + [project.name, project.description, platform.platform, brandProfile, stylePreset], + ) + const iconsLimit = plan === 'pro' ? Infinity : 5 const isExhausted = iconsUsed >= iconsLimit @@ -54,6 +103,7 @@ export function IconAiTab({ plan, user, brandProfile }: IconAiTabProps) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ description, + styleModifier: stylePreset?.ai_style_modifier ?? undefined, brandProfile: brandProfile ? { styleDirection: brandProfile.style_direction, primaryColor: brandProfile.primary_color, @@ -107,6 +157,29 @@ export function IconAiTab({ plan, user, brandProfile }: IconAiTabProps) { onChange={(e) => setDescription(e.target.value)} placeholder="e.g., minimalist checkmark icon for task app" /> + {suggestions.length > 0 && ( +
+ + + Suggested: + + {suggestions.map((s, i) => ( + + ))} +
+ )}