2544 lines
171 KiB
TypeScript
2544 lines
171 KiB
TypeScript
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||
import { Settings, ShieldCheck, LogOut, Loader2, RefreshCw, Trash2, BrainCircuit, X } from 'lucide-react';
|
||
import { CreditButton } from './components/CreditButton';
|
||
import { SEO } from './components/SEO';
|
||
import { ApiKeyModal } from './components/ApiKeyModal';
|
||
import axios from 'axios';
|
||
import { ProductType, CreativityLevel, AspectRatio, ImageSize } from './types';
|
||
import Dashboard from './Dashboard';
|
||
import Login from './pages/LoginPage';
|
||
import { useAuth } from './AuthContext';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { Tooltip } from './components/Tooltip';
|
||
import { ZoomableImage } from './components/ZoomableImage';
|
||
import { ProcessGuideGenerator, ProcessGuideRef } from './components/ProcessGuideGenerator';
|
||
import { VideoGenerator } from './components/VideoGenerator';
|
||
import { LegalModal } from './components/LegalModal';
|
||
import { Layout } from './components/Layout';
|
||
import { USER_AGREEMENT_TEXT, KVKK_TEXT, DISCLAIMER_TEXT } from './legal_texts';
|
||
|
||
import { Header } from './components/Header';
|
||
import NeuroScorecard from './components/NeuroScorecard'; // Imported NeuroScorecard
|
||
// AXIOS INSTANCE REMOVED - Using global axios for AuthContext compatibility
|
||
|
||
const Loader = ({ message }: { message: string }) => (
|
||
<div className="flex flex-col items-center justify-center p-12 space-y-4">
|
||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-stone-900"></div>
|
||
<p className="text-stone-600 font-bold animate-pulse text-xs tracking-[0.2em] uppercase text-center max-w-sm leading-loose">{message}</p>
|
||
</div>
|
||
);
|
||
|
||
const Home: React.FC = () => {
|
||
|
||
// ----------------------------------------------------------------------
|
||
// MOCKUP STUDIO CONSTANTS
|
||
// ----------------------------------------------------------------------
|
||
const CLIENT_MOCKUP_SCENARIOS: Record<string, Record<string, string>> = {
|
||
"Wall Art": {
|
||
// Classic
|
||
"living_room": "Living Room (Modern)",
|
||
"bedroom": "Bedroom (Cozy)",
|
||
"office": "Home Office",
|
||
"kitchen": "Kitchen & Dining",
|
||
"bathroom": "Luxury Bathroom",
|
||
"nursery": "Nursery (Baby)",
|
||
|
||
// Commercial
|
||
"cafe_wall": "Trendy Cafe Wall",
|
||
"restaurant_booth": "Restaurant Booth",
|
||
"bar_lounge": "Bar & Lounge (Mood)",
|
||
"asian_restaurant": "Asian Restaurant (Sushi)",
|
||
"hotel_lobby": "Hotel Lobby",
|
||
"boutique_store": "Boutique Store",
|
||
"yoga_studio": "Yoga Studio (Zen)",
|
||
"gym_wall": "Gym / Fitness",
|
||
|
||
// Specific Frames
|
||
"frame_black": "Black Frame (Clean)",
|
||
"frame_white": "White Frame (Scandi)",
|
||
"frame_gold": "Gold Frame (Luxury)",
|
||
"frame_wood": "Oak Wood Frame (Boho)",
|
||
"frame_poster": "Poster Hanger (Casual)",
|
||
|
||
// Creative
|
||
"gamer_room": "Gamer Room (Neon)",
|
||
"streamer_studio": "Streamer Studio",
|
||
"leaning_floor": "Leaning on Floor (Loft)",
|
||
"fairy_lights": "Fairy Lights (Cozy)",
|
||
"shelf_decor": "Shelf Decor",
|
||
"minimal_entryway": "Minimal Entryway",
|
||
"industrial_loft": "Industrial Loft",
|
||
|
||
// Detail
|
||
"gallery": "Art Gallery Spotlight",
|
||
"studio": "Artist Easel",
|
||
"frame_close": "Frame Detail (Macro)",
|
||
"macro_texture": "Paper Texture (Extreme Zoom)",
|
||
"macro_canvas": "Canvas Corner (Side View)",
|
||
"hand_held": "Hand Holding Print (Context)",
|
||
"ink_detail": "Ink Quality (Sharpness)"
|
||
},
|
||
"Sticker": {
|
||
// Tech
|
||
"laptop_lid": "Laptop Lid (Tech)",
|
||
"tablet_case": "Tablet Case (iPad)",
|
||
"phone_case": "Phone Case (Clear)",
|
||
"gamer_setup": "Gamer PC Case",
|
||
|
||
// Lifestyle
|
||
"sticker_bottle": "Water Bottle (Hydro)",
|
||
"travel_mug": "Travel Mug (Cozy)",
|
||
"sticker_notebook": "Notebook Cover",
|
||
"planner_spread": "Planner Page Decoration",
|
||
"clipboard": "Clipboard (Office)",
|
||
"luggage": "Travel Luggage",
|
||
"guitar_case": "Guitar Case",
|
||
|
||
// Urban
|
||
"skateboard": "Skateboard Deck",
|
||
"street_pole": "Street Pole (Urban)",
|
||
"car_bumper": "Car Bumper",
|
||
|
||
// Special
|
||
"flatlay_desk": "Desk Flatlay",
|
||
"hand_held": "Hand Held (POV)"
|
||
},
|
||
"Planner": {
|
||
"tablet": "iPad Display (Digital)",
|
||
"notebook": "Physical Planner",
|
||
"desk_flatlay": "Desk Setup",
|
||
"coffee_shop": "Coffee Shop"
|
||
},
|
||
"Bookmark": {
|
||
"in_book": "Inside Vintage Book",
|
||
"flatlay_book": "Cozy Reading Nook"
|
||
},
|
||
"Phone Wallpaper": {
|
||
"phone_lock": "Lock Screen (Hand Held)",
|
||
"phone_table": "On Coffee Table"
|
||
},
|
||
"Label": {
|
||
"jar_candle": "Candle Jar Label",
|
||
"cosmetic_bottle": "Cosmetic Bottle",
|
||
"wine_bottle": "Wine Bottle",
|
||
"shopping_bag": "Shopping Bag"
|
||
},
|
||
"Custom": {
|
||
"custom": "✨ Custom Scenario (Write your own)"
|
||
}
|
||
};
|
||
|
||
const CLIENT_ATMOSPHERES: Record<string, string> = {
|
||
"neutral": "Neutral / Day",
|
||
"warm": "Warm / Golden Hour",
|
||
"cool": "Cool / Morning",
|
||
"dark": "Dark / Moody",
|
||
"bright": "Bright / Airy",
|
||
"luxury": "Luxury / High End",
|
||
"minimalist": "Minimalist / Clean"
|
||
};
|
||
|
||
const CLIENT_ASPECT_RATIOS = ["1:1", "16:9", "9:16", "4:3", "3:4", "2:3", "3:2", "21:9", "32:9", "2.35:1", "9:21"];
|
||
const { t } = useTranslation();
|
||
const { user, logout, refreshUser } = useAuth();
|
||
|
||
|
||
// VIEW MODE: 'dashboard' | 'editor'
|
||
const [viewMode, setViewMode] = useState<'dashboard' | 'editor'>('dashboard');
|
||
const [isApiKeyModalOpen, setIsApiKeyModalOpen] = useState(false);
|
||
|
||
// Neuro-Scorecard State
|
||
const [showNeuroModal, setShowNeuroModal] = useState(false);
|
||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||
const [neuroAnalysis, setNeuroAnalysis] = useState<any>(null);
|
||
|
||
|
||
|
||
// Axios Interceptor for BYOK
|
||
useEffect(() => {
|
||
const interceptor = axios.interceptors.request.use((config) => {
|
||
const token = localStorage.getItem('token');
|
||
const apiKey = localStorage.getItem('gemini_api_key');
|
||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||
if (apiKey) config.headers['X-Gemini-API-Key'] = apiKey;
|
||
return config;
|
||
});
|
||
return () => axios.interceptors.request.eject(interceptor);
|
||
}, []);
|
||
|
||
const [niche, setNiche] = useState('');
|
||
const [aspectRatio, setAspectRatio] = useState('3:4');
|
||
const [productType, setProductType] = useState<ProductType>("Wall Art");
|
||
const [creativity, setCreativity] = useState<CreativityLevel>("Balanced");
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [loadingMsg, setLoadingMsg] = useState('');
|
||
|
||
const [useExactReference, setUseExactReference] = useState(false);
|
||
const [isStickerSet, setIsStickerSet] = useState(false);
|
||
const [setSize, setSetSize] = useState("6");
|
||
const [projectData, setProjectData] = useState<any | null>(null);
|
||
|
||
// DNA Vault
|
||
const [dnaProfiles, setDnaProfiles] = useState<{ id: string, name: string }[]>([]);
|
||
const [activeDnaProfile, setActiveDnaProfile] = useState<{ id: string, name: string } | null>(null);
|
||
useEffect(() => {
|
||
axios.get('/api/profiles').then(r => setDnaProfiles(r.data.profiles)).catch(console.error);
|
||
refreshUser();
|
||
}, [viewMode]); // Refresh when switching views
|
||
|
||
// RESTORE ACTIVE DNA PROFILE ON MOUNT
|
||
// RESTORE ACTIVE DNA PROFILE ON MOUNT
|
||
useEffect(() => {
|
||
const savedProfileId = localStorage.getItem('activeDnaProfileId');
|
||
if (savedProfileId) {
|
||
loadDnaProfile(savedProfileId);
|
||
}
|
||
}, []); // Run ONCE on mount
|
||
|
||
// URL SYNC: Load project from URL query param
|
||
useEffect(() => {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const projectId = params.get('project');
|
||
if (projectId && viewMode === 'dashboard') {
|
||
handleSelectProject(projectId);
|
||
}
|
||
}, []);
|
||
|
||
const [referenceImages, setReferenceImages] = useState<string[]>([]);
|
||
const [selectedRatio, setSelectedRatio] = useState<AspectRatio>("3:4");
|
||
const [selectedSize, setSelectedSize] = useState<ImageSize>("4K");
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
// NEW: Refine & Variants State
|
||
const [revisionBrief, setRevisionBrief] = useState('');
|
||
const [isRefining, setIsRefining] = useState(false);
|
||
const [variants, setVariants] = useState<any[]>([]);
|
||
const [variantMetadata, setVariantMetadata] = useState<Record<string, any>>({}); // Cache for hover inspection
|
||
const [isGeneratingVariants, setIsGeneratingVariants] = useState(false);
|
||
|
||
// NEW: Mockups State
|
||
const [mockups, setMockups] = useState<{ id?: string; scenario: string; aspectRatio?: string; path: string }[]>([]);
|
||
const [isGeneratingMockups, setIsGeneratingMockups] = useState(false);
|
||
const [mockupScenario, setMockupScenario] = useState('living_room');
|
||
const [customMockupPrompt, setCustomMockupPrompt] = useState("");
|
||
const [mockupRatio, setMockupRatio] = useState('16:9');
|
||
const processGuideRef = useRef<ProcessGuideRef>(null);
|
||
const [mockupAtmosphere, setMockupAtmosphere] = useState('neutral');
|
||
const [useWatermark, setUseWatermark] = useState(false); // New Watermark State
|
||
const [regeneratingMockupId, setRegeneratingMockupId] = useState<string | null>(null);
|
||
|
||
// NEW: Draft/Master Logic
|
||
const [activeAssetId, setActiveAssetId] = useState<string | null>(null);
|
||
const [isUpscaling, setIsUpscaling] = useState(false);
|
||
const [upscaleRatioOverride, setUpscaleRatioOverride] = useState<string>('auto');
|
||
const [variantFillType, setVariantFillType] = useState<string>('auto'); // 'auto', 'white', 'black'
|
||
|
||
// NEW: Videos State
|
||
const [videos, setVideos] = useState<{ id?: string; path: string; simulated?: boolean; presetId?: string }[]>([]);
|
||
|
||
// FORCE 1:1 ASPECT RATIO FOR STICKERS
|
||
useEffect(() => {
|
||
if (productType === "Sticker") {
|
||
setAspectRatio("1:1");
|
||
}
|
||
}, [productType]);
|
||
|
||
// NEW: Upscale State (DUPLICATE REMOVED)
|
||
const [upscaledAsset, setUpscaledAsset] = useState<{ id: string; path: string; width: number; height: number; printSize: string } | null>(null);
|
||
|
||
// NEW: Revision History (Last 5 versions)
|
||
const [revisionHistory, setRevisionHistory] = useState<{ path: string; brief: string; timestamp: string }[]>([]);
|
||
// NEW: Collection/Set State
|
||
const [selectedHistoryItems, setSelectedHistoryItems] = useState<string[]>([]);
|
||
const [isCreatingCollection, setIsCreatingCollection] = useState(false);
|
||
const [isPublishingEtsy, setIsPublishingEtsy] = useState(false);
|
||
const [isRegeneratingStrategy, setIsRegeneratingStrategy] = useState(false);
|
||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); // Prevents ZoomableImage click during delete confirm
|
||
|
||
// PASTE-TO-UPLOAD HANDLER
|
||
const handlePaste = useCallback((e: ClipboardEvent) => {
|
||
const items = e.clipboardData?.items;
|
||
if (!items) return;
|
||
|
||
for (let i = 0; i < items.length; i++) {
|
||
if (items[i].type.indexOf("image") !== -1) {
|
||
const blob = items[i].getAsFile();
|
||
if (!blob) continue;
|
||
const reader = new FileReader();
|
||
reader.onload = (event) => {
|
||
const base64 = event.target?.result as string;
|
||
setReferenceImages(prev => [...prev, base64]);
|
||
};
|
||
reader.readAsDataURL(blob);
|
||
}
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
console.log("Home Component Mounted");
|
||
window.addEventListener('paste', handlePaste);
|
||
return () => window.removeEventListener('paste', handlePaste);
|
||
}, [handlePaste]);
|
||
|
||
const loadDnaProfile = async (profileId: string) => {
|
||
try {
|
||
const res = await axios.get(`/api/profiles/${profileId}`);
|
||
if (res.data.profile?.images) {
|
||
setReferenceImages(res.data.profile.images);
|
||
const profile = { id: res.data.profile.id, name: res.data.profile.name };
|
||
setActiveDnaProfile(profile);
|
||
localStorage.setItem('activeDnaProfileId', profile.id);
|
||
}
|
||
} catch (e) {
|
||
console.error("Failed to load profile:", e);
|
||
}
|
||
};
|
||
|
||
const handleNewProject = () => {
|
||
// Reset Project Data & States
|
||
setProjectData(null);
|
||
setNiche('');
|
||
setReferenceImages([]);
|
||
setActiveDnaProfile(null); // Reset DNA
|
||
localStorage.removeItem('activeDnaProfileId'); // Clear Persistence
|
||
setActiveAssetId(null);
|
||
setVariants([]);
|
||
setMockups([]);
|
||
setVideos([]);
|
||
setUpscaledAsset(null);
|
||
setRevisionHistory([]);
|
||
setSelectedHistoryItems([]);
|
||
setError(null);
|
||
setLoadingMsg('');
|
||
setIsLoading(false);
|
||
|
||
// Reset Settings to Defaults
|
||
setProductType("Wall Art");
|
||
setAspectRatio("3:4");
|
||
setCreativity("Balanced");
|
||
setIsStickerSet(false);
|
||
setSetSize("6");
|
||
|
||
// Clear URL Params
|
||
const newUrl = window.location.pathname;
|
||
window.history.pushState({ path: newUrl }, '', newUrl);
|
||
|
||
// Switch to Editor View
|
||
setViewMode('editor');
|
||
};
|
||
|
||
const handleDeleteDnaProfile = async (id: string) => {
|
||
try {
|
||
await axios.delete(`/api/profiles/${id}`);
|
||
alert("DNA Profile Deleted");
|
||
|
||
// Refresh list
|
||
const r = await axios.get('/api/profiles');
|
||
setDnaProfiles(r.data.profiles);
|
||
|
||
// If it was the active one, clear it
|
||
if (activeDnaProfile?.id === id) {
|
||
setActiveDnaProfile(null);
|
||
setReferenceImages([]); // Clear broken thumbnails
|
||
localStorage.removeItem('activeDnaProfileId');
|
||
}
|
||
} catch (e) {
|
||
console.error(e);
|
||
alert("Failed to delete DNA profile");
|
||
}
|
||
};
|
||
|
||
// DASHBOARD HANDLERS
|
||
const handleSelectProject = async (id: string, updateUrl = true) => {
|
||
setIsLoading(true);
|
||
setLoadingMsg("Resurrecting Project...");
|
||
try {
|
||
const res = await axios.get(`/api/projects/${id}`);
|
||
|
||
// CRITICAL FIX: The UI expects 'assets' at the top level of projectData
|
||
// But the API returns { project: { assets: [] }, strategy: {} }
|
||
// We must HOIST the assets up and ensure structure consistency
|
||
const project = res.data.project || {};
|
||
const rawAssets = project.assets || [];
|
||
|
||
// Sort assets by date desc
|
||
const sortedAssets = [...rawAssets].sort((a: any, b: any) =>
|
||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||
);
|
||
|
||
// Construct the State Object exactly how the UI components demand it
|
||
// CRITICAL FIX: Fallback to seoData if strategy is missing (Recovered Projects Scenario)
|
||
const resolvedStrategy = res.data.strategy || res.data.project?.seoData || {};
|
||
|
||
// Parse Attributes and Map Category Field
|
||
if (typeof resolvedStrategy.attributes === 'string') {
|
||
try { resolvedStrategy.attributes = JSON.parse(resolvedStrategy.attributes); } catch (e) { }
|
||
}
|
||
if (!resolvedStrategy.categorySuggestion && resolvedStrategy.categoryPath) {
|
||
resolvedStrategy.categorySuggestion = resolvedStrategy.categoryPath;
|
||
}
|
||
|
||
const normalizedData = {
|
||
project: project,
|
||
strategy: resolvedStrategy,
|
||
assets: sortedAssets // <--- HOISTED for UI visibility
|
||
};
|
||
|
||
setProjectData(normalizedData);
|
||
setViewMode('editor');
|
||
|
||
// URL UPDATE
|
||
const newUrl = `${window.location.pathname}?project=${id}`;
|
||
if (updateUrl) {
|
||
window.history.pushState({ path: newUrl }, '', newUrl);
|
||
} else {
|
||
window.history.replaceState({ path: newUrl }, '', newUrl);
|
||
}
|
||
|
||
// Auto-select latest master/variant/upscaled asset
|
||
// FORCE PRIORITY: Upscaled > Revision > Master (Because Upscaled is the final truth)
|
||
let latestMaster = sortedAssets.find((a: any) => a.type === 'upscaled');
|
||
|
||
if (!latestMaster) {
|
||
latestMaster = sortedAssets.find((a: any) => ['master', 'revision'].includes(a.type));
|
||
}
|
||
|
||
if (latestMaster) setActiveAssetId(latestMaster.id);
|
||
|
||
// Restore Sub-States
|
||
setMockups(sortedAssets.filter((a: any) => a.type === 'mockup').map((m: any) => ({
|
||
id: m.id,
|
||
scenario: m.path.includes('mockup_') ? m.path.split('mockup_')[1].split('.png')[0] : 'custom',
|
||
path: m.path,
|
||
aspectRatio: m.meta ? JSON.parse(m.meta).aspectRatio : (project.aspectRatio || '16:9')
|
||
})) || []);
|
||
|
||
setVideos(sortedAssets.filter((a: any) => a.type === 'video').map((v: any) => ({
|
||
id: v.id,
|
||
path: v.path,
|
||
simulated: v.path.includes('sim_'),
|
||
presetId: v.meta ? JSON.parse(v.meta).presetId : undefined
|
||
})) || []);
|
||
|
||
// Restore Revision History
|
||
setRevisionHistory(sortedAssets.filter((a: any) => a.type === 'revision').map((r: any) => ({
|
||
path: r.path,
|
||
brief: r.meta ? JSON.parse(r.meta).brief : 'Revision',
|
||
timestamp: new Date(r.createdAt).toLocaleTimeString()
|
||
})));
|
||
|
||
// RESTORE VARIANTS STATE (Latest 5 only to match user expectation)
|
||
setVariants(sortedAssets.filter((a: any) => a.type === 'variant')
|
||
.map((v: any) => ({
|
||
ratio: v.meta ? JSON.parse(v.meta).ratio : '1:1',
|
||
label: v.meta ? JSON.parse(v.meta).label : 'Variant',
|
||
path: v.path,
|
||
id: v.id
|
||
})) || []);
|
||
|
||
// RESTORE UPSCALED ASSET STATE
|
||
const upscaled = sortedAssets.find((a: any) => a.type === 'upscaled');
|
||
if (upscaled) {
|
||
setUpscaledAsset({
|
||
id: upscaled.id,
|
||
path: upscaled.path,
|
||
width: upscaled.meta ? JSON.parse(upscaled.meta).width : 6000,
|
||
height: upscaled.meta ? JSON.parse(upscaled.meta).height : 8000,
|
||
printSize: upscaled.meta ? JSON.parse(upscaled.meta).printSize : '20"x30"'
|
||
});
|
||
} else {
|
||
setUpscaledAsset(null);
|
||
}
|
||
|
||
// PRE-POPULATE CREATION PANEL (So user sees original settings)
|
||
setNiche(project.niche || '');
|
||
setProductType((project.productType as ProductType) || 'Wall Art');
|
||
setCreativity((project.creativity as CreativityLevel) || 'Balanced');
|
||
setSelectedRatio((project.aspectRatio as AspectRatio) || '3:4');
|
||
setAspectRatio(project.detectedRatio || project.aspectRatio || '3:4');
|
||
// SYNC MOCKUP RATIO WITH PROJECT RATIO
|
||
setMockupRatio(project.aspectRatio || '16:9');
|
||
setUseExactReference(project.useExactReference || false);
|
||
|
||
// RESTORE REFERENCE IMAGES (Visual DNA)
|
||
const refAssets = sortedAssets.filter((a: any) => a.type === 'reference' || a.type === 'dna');
|
||
|
||
// 1. First, load Project Defaults (Safety Net)
|
||
if (refAssets.length > 0) {
|
||
const refPaths = refAssets.map((r: any) => `/storage/${r.path}`);
|
||
setReferenceImages(refPaths);
|
||
} else {
|
||
setReferenceImages([]);
|
||
}
|
||
|
||
// 2. Then, Check Session Override (User Preference)
|
||
// If the user has an active DNA profile in this session, try to load it.
|
||
const sessionProfileId = localStorage.getItem('activeDnaProfileId');
|
||
if (sessionProfileId) {
|
||
loadDnaProfile(sessionProfileId);
|
||
}
|
||
} catch (e) {
|
||
console.error(e);
|
||
if ((e as any).response?.status !== 403) {
|
||
console.warn("Project load non-fatal error", e);
|
||
}
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleDeleteProject = async (id: string) => {
|
||
if (!confirm("Delete project?")) return;
|
||
try {
|
||
await axios.delete(`/api/projects/${id}`);
|
||
alert("Project deleted");
|
||
window.location.reload(); // Simple reload to refresh dashboard
|
||
} catch (e) {
|
||
alert("Failed to delete");
|
||
}
|
||
};
|
||
|
||
const handleCreateProject = async () => {
|
||
console.log("handleCreateProject called"); // DEBUG
|
||
// Validation for Credit Check handled by backend 402
|
||
if (!process.env.NODE_ENV && !user?.apiKey) {
|
||
// In dev mode we might skip, but in prod we need key
|
||
}
|
||
|
||
setIsLoading(true);
|
||
setLoadingMsg("Initializing Creative Strategy..."); // Step 1
|
||
setError(null);
|
||
|
||
try {
|
||
console.log("Sending POST to /api/projects..."); // DEBUG
|
||
const res = await axios.post('/api/projects', {
|
||
niche,
|
||
productType,
|
||
creativity,
|
||
referenceImages,
|
||
aspectRatio,
|
||
useExactReference,
|
||
isStickerSet,
|
||
setSize
|
||
});
|
||
console.log("Create Response:", res.data); // DEBUG
|
||
|
||
// If successful, load project - NORMALIZE STRUCTURE TO MATCH handleSelectProject
|
||
const newProject = res.data.project;
|
||
setProjectData({
|
||
project: newProject,
|
||
strategy: newProject.strategy || {},
|
||
assets: newProject.assets || []
|
||
});
|
||
await refreshUser(); // Update credits
|
||
setViewMode('editor');
|
||
} catch (err: any) {
|
||
console.error("Create Project Error:", err); // DEBUG
|
||
const msg = err.response?.data?.error || err.message;
|
||
if (err.response?.status === 402) {
|
||
alert(`⚠️ ${msg}`);
|
||
}
|
||
alert(`Creation Failed: ${msg}`); // FORCE ALERT
|
||
setError(msg);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleRefine = async () => {
|
||
const projectId = projectData?.project?.id || projectData?.id;
|
||
// alert(`DEBUG: Refine Clicked. ProjectID: ${projectId}`); // REMOVED
|
||
console.log("[Client] handleRefine triggered. ID:", projectId);
|
||
if (!projectId) return;
|
||
|
||
setIsRefining(true);
|
||
setError(null);
|
||
|
||
try {
|
||
// Optimistic Update? No, wait for result.
|
||
const response = await axios.post(`/api/projects/${projectId}/refine`, {
|
||
revisionBrief: revisionBrief,
|
||
sourceAssetId: activeAssetId // <--- Explicitly tell backend WHICH image to refine
|
||
});
|
||
|
||
// Update UI with the full project data returned from backend
|
||
if (response.data.project) {
|
||
const project = response.data.project;
|
||
const sortedAssets = [...(project.assets || [])].sort((a: any, b: any) =>
|
||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||
);
|
||
|
||
setProjectData({
|
||
project: project,
|
||
strategy: response.data.strategy || projectData.strategy,
|
||
assets: sortedAssets
|
||
});
|
||
|
||
// Auto Select New Master (it might have a new ID if we replaced it, or same ID but updated path)
|
||
const latestMaster = sortedAssets.find((a: any) => ['master', 'revision', 'variant'].includes(a.type));
|
||
if (latestMaster) setActiveAssetId(latestMaster.id);
|
||
}
|
||
|
||
setRevisionBrief('');
|
||
await refreshUser();
|
||
} catch (err: any) {
|
||
console.error("[Client] Refine Error:", err);
|
||
const msg = err.response?.data?.error || err.message;
|
||
alert(`REFINE FAILED: ${msg}`); // FORCE ERROR VISIBILITY
|
||
if (err.response?.status === 402) alert(`⚠️ ${msg}`);
|
||
setError(`Refine Error: ${msg}`);
|
||
} finally {
|
||
setIsRefining(false);
|
||
}
|
||
};
|
||
|
||
const handleGenerateVariants = async () => {
|
||
const projectId = projectData?.project?.id || projectData?.id;
|
||
if (!projectId) return;
|
||
setIsGeneratingVariants(true);
|
||
setError(null);
|
||
|
||
try {
|
||
const response = await axios.post(`/api/projects/${projectId}/variants`, {
|
||
variantFillType: variantFillType // Pass user preference
|
||
});
|
||
setVariants(response.data.variants || []);
|
||
await refreshUser();
|
||
} catch (err: any) {
|
||
const msg = err.response?.data?.error || err.message;
|
||
if (err.response?.status === 402) alert(`⚠️ ${msg}`);
|
||
setError(`Variants Error: ${msg}`);
|
||
} finally {
|
||
setIsGeneratingVariants(false);
|
||
}
|
||
};
|
||
|
||
const handleVariantHover = useCallback(async (id: string) => {
|
||
// If already cached, don't refetch
|
||
if (variantMetadata[id]) return;
|
||
|
||
try {
|
||
const res = await axios.get(`/api/assets/${id}/metadata`);
|
||
setVariantMetadata(prev => ({ ...prev, [id]: res.data }));
|
||
} catch (e) {
|
||
console.error("Failed to inspect variant", e);
|
||
}
|
||
}, [variantMetadata]);
|
||
|
||
const handleRegenerateVariants = async () => {
|
||
if (!confirm("This will DELETE all current variants and regenerate them based on the SELECTED image. Continue?")) return;
|
||
setIsGeneratingVariants(true);
|
||
try {
|
||
// Determine Source ID (Strip prefix if any)
|
||
const sourceAssetId = activeAssetId ? activeAssetId.replace('lightbox_', '') : null;
|
||
|
||
// 1. Delete All
|
||
await axios.delete(`/api/projects/${projectData.project.id}/variants`);
|
||
setVariants([]); // Clear UI immediately
|
||
|
||
// 2. Regenerate with SOURCE
|
||
const response = await axios.post(`/api/projects/${projectData.project.id}/variants`, {
|
||
regenerate: true,
|
||
sourceAssetId: sourceAssetId,
|
||
variantFillType: variantFillType // Pass user preference
|
||
});
|
||
|
||
if (response.data.success && response.data.variants) {
|
||
setVariants(response.data.variants.map((v: any) => ({
|
||
ratio: v.ratio,
|
||
label: v.label,
|
||
path: v.path,
|
||
id: v.id
|
||
})));
|
||
}
|
||
|
||
// 3. Update State (Force refresh to get correct meta)
|
||
handleSelectProject(projectData.project.id, false);
|
||
|
||
} catch (e: any) {
|
||
alert("Regeneration failed: " + e.message);
|
||
} finally {
|
||
setIsGeneratingVariants(false);
|
||
}
|
||
};
|
||
|
||
const handleDownloadCricut = async (assetId: string) => {
|
||
try {
|
||
const res = await axios.get(`/api/assets/${assetId}/cricut-package`, { responseType: 'blob' });
|
||
const url = window.URL.createObjectURL(new Blob([res.data]));
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.setAttribute('download', `cricut_pack_${assetId.substring(0, 8)}.zip`);
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
link.remove();
|
||
} catch (e) {
|
||
console.error("Cricut download failed", e);
|
||
alert("Failed to download Cricut package.");
|
||
}
|
||
};
|
||
|
||
const handleGenerateStickerSheet = async () => {
|
||
if (!projectData?.project?.id) return;
|
||
setIsLoading(true);
|
||
setLoadingMsg("Compositing Sticker Sheet...");
|
||
try {
|
||
// Default to A4 for now, or use the project's default if it matches paper size?
|
||
// Let's use A4 as safe default.
|
||
const response = await axios.post(`/api/projects/${projectData.project.id}/sheet`, { paperSize: 'A4' });
|
||
|
||
// Reload assets to show the new sheet
|
||
setLoadingMsg("Reloading Assets...");
|
||
await handleSelectProject(projectData.project.id, false);
|
||
alert("Sticker Sheet Generated! Check the 'Sheets' section (or look for the new asset).");
|
||
|
||
} catch (e: any) {
|
||
console.error(e);
|
||
alert("Failed to generate sheet: " + (e.response?.data?.error || e.message));
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
// MOCKUPS HANDLER
|
||
const handleUpscale = async () => {
|
||
const projectId = projectData?.project?.id || projectData?.id;
|
||
// alert(`DEBUG: Upscale Clicked. ProjectID: ${projectId}`); // REMOVED
|
||
console.log("handleUpscale called. ActiveAssetId:", activeAssetId);
|
||
if (!activeAssetId) {
|
||
console.warn("handleUpscale aborted: No activeAssetId");
|
||
return;
|
||
}
|
||
setIsUpscaling(true);
|
||
try {
|
||
console.log("Sending upscale request for:", activeAssetId);
|
||
const res = await axios.post(`/api/projects/${projectId}/upscale`, { assetId: activeAssetId });
|
||
// Refresh data
|
||
handleSelectProject(projectId);
|
||
// Auto-select the new master asset logic will be handled in useEffect
|
||
} catch (e: any) {
|
||
const msg = e.response?.data?.error || "Upscale failed";
|
||
alert(`UPSCALE FAILED: ${msg}`); // FORCE ERROR VISIBILITY
|
||
setError(msg);
|
||
}
|
||
};
|
||
|
||
const handleGenerateMockups = async () => {
|
||
const projectId = projectData?.project?.id || projectData?.id;
|
||
if (!projectId) return;
|
||
setIsGeneratingMockups(true);
|
||
setError(null);
|
||
|
||
// SPECIAL HANDLING: Process Guide
|
||
if (mockupScenario === 'process_guide') {
|
||
if (processGuideRef.current) {
|
||
try {
|
||
await processGuideRef.current.generate();
|
||
} catch (e) {
|
||
console.error("Guide generation failed");
|
||
}
|
||
}
|
||
setIsGeneratingMockups(false);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Batch generation
|
||
const response = await axios.post(`/api/projects/${projectId}/mockups`, {
|
||
watermarkOptions: { enabled: useWatermark }
|
||
});
|
||
|
||
// Add new mockups to list
|
||
if (response.data.mockups) {
|
||
setMockups(prev => [...prev, ...response.data.mockups]);
|
||
}
|
||
await refreshUser();
|
||
} catch (err: any) {
|
||
const msg = err.response?.data?.error || err.message;
|
||
if (err.response?.status === 402) alert(`⚠️ ${msg}`);
|
||
setError(`Mockup Error: ${msg}`);
|
||
} finally {
|
||
setIsGeneratingMockups(false);
|
||
}
|
||
};
|
||
|
||
// REGENERATE SINGLE MOCKUP
|
||
const handleRegenerateMockup = async (id: string, scenario: string, ratio: string) => {
|
||
setRegeneratingMockupId(id);
|
||
|
||
try {
|
||
await handleGenerateMockups();
|
||
} catch (e) {
|
||
alert("Regeneration failed");
|
||
} finally {
|
||
setRegeneratingMockupId(null);
|
||
}
|
||
}
|
||
|
||
// GENERATE SINGLE MOCKUP (New)
|
||
const handleGenerateSingleMockup = async () => {
|
||
const projectId = projectData?.project?.id || projectData?.id;
|
||
// alert(`DEBUG: Mockup Clicked. ProjectID: ${projectId}`); // REMOVED
|
||
console.log("[Client] handleGenerateSingleMockup triggered. ID:", projectId);
|
||
if (!projectId) return;
|
||
|
||
setIsGeneratingMockups(true);
|
||
setError(null);
|
||
|
||
// Find MASTER ASSET to use
|
||
let targetAssetId = activeAssetId;
|
||
|
||
if (!targetAssetId) {
|
||
// Fallback to searching in the hoisted assets array
|
||
const assets = projectData.assets || projectData.project.assets || [];
|
||
const master = assets.find((a: any) => a.type === 'master' || a.type === 'MASTER');
|
||
if (master) targetAssetId = master.id;
|
||
}
|
||
|
||
if (!targetAssetId) {
|
||
alert("Please select a Master Asset or Generate one first!");
|
||
setIsGeneratingMockups(false);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await axios.post(`/api/projects/${projectId}/mockup`, {
|
||
selectedScenario: mockupScenario,
|
||
selectedRatio: mockupRatio,
|
||
atmosphere: mockupAtmosphere,
|
||
customPrompt: mockupScenario === 'custom' ? customMockupPrompt : undefined,
|
||
masterAssetId: targetAssetId,
|
||
watermarkOptions: {
|
||
enabled: useWatermark,
|
||
opacity: 30, // Default opacity
|
||
position: 'center'
|
||
}
|
||
}); if (response.data.mockup) {
|
||
setMockups(prev => [response.data.mockup, ...prev]);
|
||
}
|
||
await refreshUser();
|
||
} catch (err: any) {
|
||
const msg = err.response?.data?.error || err.message;
|
||
if (err.response?.status === 402) alert(`⚠️ ${msg}`);
|
||
setError(`Mockup Error: ${msg}`);
|
||
} finally {
|
||
setIsGeneratingMockups(false);
|
||
}
|
||
};
|
||
|
||
|
||
const handleDeleteAsset = async (assetId: string, type: string) => {
|
||
// Confirmation is handled by the calling button - no duplicate confirm here
|
||
console.log(`[DELETE] Starting delete for asset: ${assetId} (type: ${type})`);
|
||
|
||
try {
|
||
const response = await axios.delete(`/api/assets/${assetId}`);
|
||
console.log(`[DELETE] Server response:`, response.data);
|
||
|
||
// Remove from UI based on type
|
||
if (type === 'mockup') {
|
||
setMockups(prev => prev.filter((m: any) => m.id !== assetId));
|
||
} else if (type === 'video') {
|
||
setVideos(prev => prev.filter((v: any) => v.id !== assetId));
|
||
} else if (type === 'variant') {
|
||
setVariants(prev => prev.filter((v: any) => v.id !== assetId));
|
||
} else if (type === 'upscaled') {
|
||
setUpscaledAsset(null);
|
||
}
|
||
|
||
// CRITICAL: Update projectData assets for ALL types (master, revision, upscaled, etc.)
|
||
setProjectData((prev: any) => {
|
||
const newAssets = prev.assets ? prev.assets.filter((a: any) => a.id !== assetId) : [];
|
||
console.log(`[DELETE] Updated projectData: ${prev.assets?.length || 0} -> ${newAssets.length} assets`);
|
||
return {
|
||
...prev,
|
||
assets: newAssets
|
||
};
|
||
});
|
||
|
||
// Reset active asset if we deleted the currently active one
|
||
if (activeAssetId === assetId) {
|
||
setActiveAssetId(null);
|
||
}
|
||
|
||
console.log(`[DELETE] Successfully deleted asset: ${assetId}`);
|
||
|
||
} catch (e: any) {
|
||
console.error(`[DELETE] Error deleting asset:`, e);
|
||
const errorMsg = e.response?.data?.error || e.message || "Unknown error";
|
||
alert(`Delete failed: ${errorMsg}`);
|
||
}
|
||
}
|
||
|
||
|
||
if (!user) {
|
||
return <Login />;
|
||
}
|
||
|
||
if (viewMode === 'dashboard') {
|
||
return (
|
||
<Layout> {/* Use Layout wrapper for Dashboard too to keep Header */}
|
||
<Dashboard
|
||
onNewProject={handleNewProject}
|
||
onSelectProject={handleSelectProject}
|
||
/>
|
||
</Layout>
|
||
);
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// NEURO-SCORECARD HANDLER
|
||
// ----------------------------------------------------------------------
|
||
const handleNeuroAnalyze = async (assetId: string) => {
|
||
const asset = projectData.assets.find((prev: any) => prev.id === assetId);
|
||
if (!asset) return;
|
||
|
||
setShowNeuroModal(true); // Ensure modal opens
|
||
setIsAnalyzing(true);
|
||
setNeuroAnalysis(null);
|
||
|
||
try {
|
||
// Fetch the image data (blind fetch for now, assuming server handles path)
|
||
const cleanPath = asset.path.startsWith('/') ? asset.path.substring(1) : asset.path;
|
||
|
||
// We need to convert the image to base64 to send it to the API
|
||
// Or if the API supports path, send path.
|
||
// The existing /api/neuro-score expects imageBase64.
|
||
// Let's fetch the image client-side to get blob, then base64.
|
||
|
||
const response = await fetch(`/storage/${cleanPath}`);
|
||
const blob = await response.blob();
|
||
|
||
const reader = new FileReader();
|
||
reader.readAsDataURL(blob);
|
||
reader.onloadend = async () => {
|
||
const base64data = reader.result as string;
|
||
|
||
try {
|
||
const res = await axios.post('/api/neuro-score', {
|
||
imageBase64: base64data
|
||
});
|
||
setNeuroAnalysis(res.data.data);
|
||
} catch (err: any) {
|
||
console.error("Analysis Failed:", err);
|
||
// Don't close modal on error, show alert
|
||
alert("Analysis Failed: " + (err.response?.data?.error || err.message));
|
||
} finally {
|
||
setIsAnalyzing(false);
|
||
}
|
||
};
|
||
} catch (error) {
|
||
console.error("Failed to load image for analysis", error);
|
||
setIsAnalyzing(false);
|
||
setShowNeuroModal(false);
|
||
}
|
||
};
|
||
|
||
const handleApplyNeuroFix = (suggestion: string) => {
|
||
setRevisionBrief(prev => {
|
||
const separator = prev.length > 0 ? " " : "";
|
||
return `${prev}${separator}${suggestion}`;
|
||
});
|
||
|
||
// Scroll to refine input
|
||
const refineSection = document.getElementById('refine-section');
|
||
if (refineSection) {
|
||
refineSection.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
const input = refineSection.querySelector('input');
|
||
if (input) input.focus();
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Layout>
|
||
<SEO
|
||
title={projectData ? `${projectData.strategy?.seoTitle} | Editor` : "New Project"}
|
||
description="AI-Powered Etsy Asset Generator"
|
||
/>
|
||
|
||
<ApiKeyModal isOpen={isApiKeyModalOpen} onClose={() => setIsApiKeyModalOpen(false)} />
|
||
|
||
{/* Hidden Process Guide Generator */}
|
||
<div className="hidden">
|
||
<ProcessGuideGenerator
|
||
ref={processGuideRef}
|
||
project={projectData?.project}
|
||
onGenerate={() => {/* maybe reload assets? */ }}
|
||
/>
|
||
</div>
|
||
|
||
{/* MAIN EDITOR UI */}
|
||
<main className="min-h-screen bg-stone-50 pb-20">
|
||
|
||
|
||
{/* TOOLBAR */}
|
||
<div className="z-30 bg-white/80 backdrop-blur-md border-b border-stone-200 px-6 py-4 flex justify-between items-center shadow-sm">
|
||
<div className="flex items-center gap-4">
|
||
<button onClick={handleNewProject} className="p-2 hover:bg-stone-100 rounded-full transition-colors" title="Gallery Vault">
|
||
<span className="text-xl">←</span>
|
||
</button>
|
||
<div>
|
||
<h2 className="font-black text-lg text-stone-900 leading-none">
|
||
{projectData ? (projectData.strategy?.seoTitle || projectData.project?.niche || "Untitled Project").substring(0, 30) + (projectData.strategy?.seoTitle ? '...' : '') : 'New Project'}
|
||
</h2>
|
||
<span className="text-[10px] font-bold uppercase tracking-widest text-stone-400">{projectData ? 'Editing Mode' : 'Setup Phase'}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<div className="hidden md:flex flex-col items-end mr-4">
|
||
<span className="text-[10px] uppercase font-black tracking-widest text-stone-400">Project Budget</span>
|
||
<span className="text-xs font-bold font-mono text-stone-600">${projectData?.project?.totalCost?.toFixed(4) || '0.000'}</span>
|
||
</div>
|
||
<CreditButton cost={0} onClick={() => { }} disabled className="bg-stone-100 text-stone-400 cursor-default px-4">
|
||
{isLoading ? 'Processing...' : 'Ready'}
|
||
</CreditButton>
|
||
</div>
|
||
</div>
|
||
|
||
{!projectData ? (
|
||
// ----------------------------------------------------------------------
|
||
// SETUP WIZARD (New Project)
|
||
// ----------------------------------------------------------------------
|
||
<div className="max-w-4xl mx-auto mt-12 px-4">
|
||
<div className="bg-white rounded-[2rem] p-8 shadow-xl border border-stone-100 relative overflow-hidden">
|
||
{/* Background Decoration */}
|
||
<div className="absolute top-0 right-0 w-64 h-64 bg-gradient-to-br from-purple-50 to-blue-50 rounded-bl-[10rem] -z-10 opacity-50" />
|
||
|
||
<div className="mb-8 text-center">
|
||
<h1 className="text-3xl font-black text-stone-900 tracking-tight mb-2">New Creation</h1>
|
||
<p className="text-stone-500 text-sm font-medium">Define your vision. The AI will handle the strategy.</p>
|
||
</div>
|
||
|
||
<div className="space-y-6">
|
||
{/* Row 1: Niche */}
|
||
<div>
|
||
<label className="text-[10px] font-black uppercase tracking-widest text-stone-400 mb-2 block">Product Niche</label>
|
||
<input
|
||
type="text"
|
||
value={niche}
|
||
onChange={(e) => setNiche(e.target.value)}
|
||
placeholder="e.g., 'Boho Nursery Wall Art', 'Minimalist Planner'..."
|
||
className="w-full text-base font-bold bg-stone-50 border border-stone-200 rounded-xl px-4 py-3 focus:border-stone-900 focus:outline-none transition-colors placeholder:text-stone-300"
|
||
/>
|
||
</div>
|
||
|
||
{/* Row 2: Parameters Grid */}
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||
{/* Product Type */}
|
||
<div>
|
||
<label className="text-[10px] font-black uppercase tracking-widest text-stone-400 mb-2 block">Product Type</label>
|
||
<div className="flex flex-wrap gap-2">
|
||
{["Wall Art", "Sticker", "Planner", "Bookmark", "Label"].map((type) => (
|
||
<button
|
||
key={type}
|
||
onClick={() => setProductType(type as ProductType)}
|
||
className={`px-3 py-1.5 rounded-lg font-bold text-[10px] transition-all ${productType === type ? 'bg-stone-900 text-white shadow-md' : 'bg-stone-100 text-stone-500 hover:bg-stone-200'}`}
|
||
>
|
||
{type}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Sticker Set Configuration */}
|
||
{productType === "Sticker" && (
|
||
<div className="col-span-full bg-purple-50 p-4 rounded-xl border border-purple-100 flex items-center gap-6 animate-fade-in">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-10 h-6 bg-purple-200 rounded-full p-1 cursor-pointer transition-colors relative" onClick={() => setIsStickerSet(!isStickerSet)}>
|
||
<div className={`w-4 h-4 bg-purple-600 rounded-full shadow-sm transition-transform ${isStickerSet ? 'translate-x-4' : 'translate-x-0'}`} />
|
||
</div>
|
||
<label className="text-xs font-black uppercase tracking-widest text-purple-900 cursor-pointer" onClick={() => setIsStickerSet(!isStickerSet)}>
|
||
Generate Sticker Set
|
||
</label>
|
||
</div>
|
||
|
||
{isStickerSet && (
|
||
<div className="flex items-center gap-3">
|
||
<label className="text-[10px] font-black uppercase tracking-widest text-purple-400">Set Size</label>
|
||
<select
|
||
value={setSize}
|
||
onChange={(e) => setSetSize(e.target.value)}
|
||
className="bg-white border border-purple-200 text-purple-900 text-xs font-bold rounded-lg px-3 py-1.5 outline-none focus:ring-2 focus:ring-purple-400 cursor-pointer"
|
||
>
|
||
{[6, 9, 12, 16, 20, 25].map(n => <option key={n} value={n}>{n} Stickers</option>)}
|
||
</select>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Aspect Ratio */}
|
||
<div>
|
||
<label className="text-[10px] font-black uppercase tracking-widest text-stone-400 mb-2 block flex justify-between">
|
||
<span>Orientation / Ratio</span>
|
||
<span className="text-stone-900">{aspectRatio}</span>
|
||
</label>
|
||
<div className="flex items-center gap-2">
|
||
<select
|
||
value={selectedRatio}
|
||
onChange={(e) => {
|
||
const r = e.target.value as AspectRatio;
|
||
setSelectedRatio(r);
|
||
setAspectRatio(r);
|
||
}}
|
||
className="w-full bg-white border border-stone-200 text-stone-700 px-3 py-2 rounded-lg text-xs font-bold focus:ring-1 focus:ring-stone-300 cursor-pointer outline-none appearance-none"
|
||
>
|
||
{productType === "Sticker" ? (
|
||
// STICKER MODE: Only Paper Sizes
|
||
["A4", "A5", "Letter"].map(r => (
|
||
<option key={r} value={r}>{r} (Paper)</option>
|
||
))
|
||
) : (
|
||
// STANDARD MODE: All Ratios
|
||
["1:1", "3:4", "4:3", "2:3", "3:2", "9:16", "16:9", "4:5", "5:4", "A4", "Letter"].map(r => (
|
||
<option key={r} value={r}>{r} {['A4', 'Letter', 'A5'].includes(r) ? '(Paper)' : r === '1:1' ? '(Square)' : (parseInt(r.split(':')[0]) > parseInt(r.split(':')[1]) ? '(Landscape)' : '(Portrait)')}</option>
|
||
))
|
||
)}
|
||
</select>
|
||
|
||
{(() => {
|
||
// Handle Paper Sizes
|
||
if (aspectRatio === "A4" || aspectRatio === "A5") return <span className="text-xl grayscale opacity-50 px-1">📄</span>;
|
||
if (aspectRatio === "Letter") return <span className="text-xl grayscale opacity-50 px-1">📝</span>;
|
||
|
||
const parts = aspectRatio.split(':');
|
||
const w = parseInt(parts[0]);
|
||
const h = parseInt(parts[1]);
|
||
const icon = w > h ? '🖼️' : w < h ? '📱' : '⬜';
|
||
return <span className="text-xl grayscale opacity-50 px-1">{icon}</span>
|
||
})()}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Creativity Level */}
|
||
<div>
|
||
<label className="text-[10px] font-black uppercase tracking-widest text-stone-400 mb-2 block flex justify-between">
|
||
<span>Creative Freedom</span>
|
||
<span className="text-stone-900">{creativity}</span>
|
||
</label>
|
||
<input
|
||
type="range"
|
||
min="0" max="2" step="1"
|
||
value={creativity === "Conservative" ? 0 : creativity === "Balanced" ? 1 : 2}
|
||
onChange={(e) => setCreativity(e.target.value === "0" ? "Conservative" : e.target.value === "1" ? "Balanced" : "Wild")}
|
||
className="w-full accent-stone-900 cursor-pointer h-2 bg-stone-200 rounded-full appearance-none"
|
||
/>
|
||
<div className="flex justify-between text-[8px] font-bold uppercase tracking-widest text-stone-300 mt-1">
|
||
<span>Strict</span>
|
||
<span>Balanced</span>
|
||
<span>Wild</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Row 3: Quality & DNA */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 items-start">
|
||
{/* Quality Selection */}
|
||
<div className="bg-stone-50/50 rounded-2xl p-4 border border-stone-100 h-full">
|
||
<label className="text-[10px] font-black uppercase tracking-widest text-stone-400 mb-3 block">Base Quality (Draft Scale)</label>
|
||
<div className="grid grid-cols-3 gap-2">
|
||
{[
|
||
{ id: "SD", l: "Standard", desc: "Fast & Stable" },
|
||
{ id: "HD", l: "High Def", desc: "Detailed" },
|
||
{ id: "4K", l: "Ultra", desc: "Best Finish" }
|
||
].map((q) => (
|
||
<button
|
||
key={q.id}
|
||
onClick={() => setSelectedSize(q.id as ImageSize)}
|
||
className={`p-2 rounded-xl border-2 transition-all flex flex-col items-center gap-0.5 ${selectedSize === q.id ? 'border-stone-900 bg-white shadow-sm' : 'border-transparent bg-stone-100 hover:bg-stone-200'}`}
|
||
>
|
||
<span className={`text-[10px] font-black ${selectedSize === q.id ? 'text-stone-900' : 'text-stone-400'}`}>{q.id}</span>
|
||
<span className="text-[8px] font-bold text-stone-400 uppercase tracking-tighter">{q.l}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<p className="mt-3 text-[9px] text-stone-400 font-medium">Drafts are generated at these scales. Upscaling to 6000px+ is available in the next step.</p>
|
||
</div>
|
||
|
||
{/* Visual DNA */}
|
||
<div className="bg-stone-50/50 rounded-2xl p-4 border border-stone-100 h-full">
|
||
<div className="flex flex-col gap-4 mb-4">
|
||
<div className="flex items-center justify-between">
|
||
<label className="text-[10px] font-black uppercase tracking-widest text-stone-400 flex items-center gap-2">
|
||
<span>Visual DNA (References)</span>
|
||
<span className="bg-stone-200 text-stone-600 px-1.5 py-0.5 rounded text-[9px]">{referenceImages.length}</span>
|
||
</label>
|
||
|
||
{/* STYLE vs STRUCTURE TOGGLE */}
|
||
<button
|
||
onClick={() => setUseExactReference(!useExactReference)}
|
||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-wider transition-all border ${useExactReference
|
||
? 'bg-indigo-50 text-indigo-600 border-indigo-200 shadow-sm'
|
||
: 'bg-white text-stone-400 border-stone-200 hover:text-stone-600 hover:border-stone-300'
|
||
}`}
|
||
>
|
||
<span className="text-sm">{useExactReference ? '📐 Structure' : '🎨 Style'}</span>
|
||
<Tooltip content={useExactReference ? "Matches exact composition & shapes" : "Captures vibe, colors & textures"} position="bottom">
|
||
<span className="ml-1 opacity-50 cursor-help">ⓘ</span>
|
||
</Tooltip>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
{/* DNA DROPDOWN */}
|
||
<select
|
||
value={activeDnaProfile?.id || ""}
|
||
onChange={(e) => {
|
||
if (e.target.value === "") {
|
||
setActiveDnaProfile(null);
|
||
setReferenceImages([]);
|
||
localStorage.removeItem('activeDnaProfileId');
|
||
} else {
|
||
loadDnaProfile(e.target.value);
|
||
}
|
||
}}
|
||
className="bg-white border border-stone-200 text-stone-600 text-[9px] font-bold rounded-lg px-2 py-1.5 outline-none focus:border-stone-400 cursor-pointer uppercase tracking-wide flex-1 min-w-[120px]"
|
||
>
|
||
<option value="">📂 {activeDnaProfile ? 'Unsaved / New' : 'Load DNA...'}</option>
|
||
{dnaProfiles.map((p: any) => (
|
||
<option key={p.id} value={p.id}>{p.name}</option>
|
||
))}
|
||
</select>
|
||
|
||
{/* DNA ACTIONS (Visible if images exist) */}
|
||
{referenceImages.length > 0 && (
|
||
<div className="flex items-center gap-1.5">
|
||
{activeDnaProfile && (
|
||
<>
|
||
<button
|
||
onClick={async () => {
|
||
if (window.confirm(`⚠️ ATTENTION: You are about to overwrite '${activeDnaProfile.name}'. Proceed?`)) {
|
||
try {
|
||
await axios.post('/api/profiles', { name: activeDnaProfile.name, referenceImages, overwrite: true });
|
||
alert("Profile Updated!");
|
||
const r = await axios.get('/api/profiles');
|
||
setDnaProfiles(r.data.profiles);
|
||
} catch (e) { alert("Update failed"); }
|
||
}
|
||
}}
|
||
className="text-emerald-600 bg-emerald-50 border border-emerald-100 p-1.5 rounded-lg hover:bg-emerald-100 transition-colors shadow-sm"
|
||
title="Update Profile"
|
||
>
|
||
💾
|
||
</button>
|
||
<button
|
||
onClick={() => handleDeleteDnaProfile(activeDnaProfile.id)}
|
||
className="text-red-500 bg-red-50 border border-red-100 p-1.5 rounded-lg hover:bg-red-100 transition-colors shadow-sm"
|
||
title="Delete Profile"
|
||
>
|
||
🗑️
|
||
</button>
|
||
</>
|
||
)}
|
||
|
||
<button
|
||
onClick={async () => {
|
||
const name = prompt(activeDnaProfile ? "Name this copy:" : "Name this DNA Profile:");
|
||
if (!name) return;
|
||
const existing = dnaProfiles.find((p: any) => p.name.toLowerCase() === name.toLowerCase());
|
||
if (existing && !window.confirm(`⚠️ COLLISION: A profile named '${name}' already exists. Overwrite?`)) return;
|
||
try {
|
||
await axios.post('/api/profiles', { name, referenceImages, overwrite: !!existing });
|
||
alert("Profile Saved!");
|
||
const r = await axios.get('/api/profiles');
|
||
setDnaProfiles(r.data.profiles);
|
||
const np = r.data.profiles.find((p: any) => p.name === name);
|
||
if (np) { setActiveDnaProfile(np); localStorage.setItem('activeDnaProfileId', np.id); }
|
||
} catch (e) { alert("Save failed"); }
|
||
}}
|
||
className={`text-[9px] font-black uppercase tracking-widest px-2.5 py-1.5 rounded-lg border shadow-sm transition-all ${activeDnaProfile ? 'bg-white text-indigo-600 border-indigo-200' : 'bg-indigo-600 text-white border-indigo-700'}`}
|
||
>
|
||
{activeDnaProfile ? 'Copy' : 'Save DNA'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-hide">
|
||
{referenceImages.map((src, i) => (
|
||
<div key={i} className="relative aspect-square w-14 h-14 shrink-0 rounded-lg overflow-hidden group border border-stone-200">
|
||
<img src={src} className="w-full h-full object-cover" />
|
||
<button onClick={() => setReferenceImages(p => p.filter((_, idx) => idx !== i))} className="absolute inset-0 bg-black/50 text-white opacity-0 group-hover:opacity-100 flex items-center justify-center font-bold text-xs transition-opacity">×</button>
|
||
</div>
|
||
))}
|
||
<div className="relative aspect-square w-14 h-14 shrink-0 border-2 border-dashed border-stone-200 rounded-lg flex flex-col items-center justify-center hover:bg-white hover:border-purple-300 cursor-pointer overflow-hidden transition-all group bg-white">
|
||
<span className="text-lg text-stone-300 group-hover:text-purple-400 transition-colors">+</span>
|
||
<input id="wizard_dna_upload" type="file" multiple onChange={e => {
|
||
if (!e.target.files) return;
|
||
Array.from(e.target.files).forEach((f: any) => {
|
||
const r = new FileReader();
|
||
r.onload = ev => setReferenceImages(p => [...p, ev.target?.result as string]);
|
||
r.readAsDataURL(f);
|
||
});
|
||
}} className="absolute inset-0 opacity-0 cursor-pointer" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-8">
|
||
<CreditButton
|
||
cost={1} // DRAFT COST
|
||
onClick={handleCreateProject}
|
||
disabled={!niche}
|
||
className="w-full bg-stone-900 text-white py-4 rounded-xl text-sm font-black uppercase tracking-widest hover:scale-[1.01] transition-transform shadow-xl disabled:opacity-50"
|
||
>
|
||
{isLoading ? <Loader2 className="animate-spin w-4 h-4" /> : `Start Creation`}
|
||
</CreditButton>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
// ----------------------------------------------------------------------
|
||
// PROJECT EDITOR
|
||
// ----------------------------------------------------------------------
|
||
<div className="max-w-[1600px] mx-auto p-6 md:p-12">
|
||
{isLoading && (
|
||
<div className="fixed inset-0 bg-white/80 backdrop-blur-sm z-50 flex items-center justify-center">
|
||
<Loader message={loadingMsg} />
|
||
</div>
|
||
)}
|
||
|
||
{/* CREATION SUMMARY PANEL (Blueprint) */}
|
||
<details className="mb-8 bg-white rounded-2xl shadow-sm border border-stone-200 overflow-hidden group">
|
||
<summary className="cursor-pointer px-6 py-4 flex items-center justify-between hover:bg-stone-50 transition-colors">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-8 h-8 rounded-lg bg-stone-100 flex items-center justify-center text-stone-500 font-black">📋</div>
|
||
<h3 className="font-bold text-sm text-stone-900 uppercase tracking-wide">Project Blueprint</h3>
|
||
</div>
|
||
<span className="text-stone-300 text-xs group-open:rotate-180 transition-transform">▼</span>
|
||
</summary>
|
||
<div className="px-6 py-6 border-t border-stone-100 bg-stone-50/50 space-y-6">
|
||
{/* Prompt Niche */}
|
||
<div>
|
||
<label className="text-[10px] font-black uppercase tracking-widest text-stone-400 block mb-2">Original Prompt / Niche</label>
|
||
<textarea
|
||
value={niche}
|
||
onChange={(e) => setNiche(e.target.value)}
|
||
className="w-full bg-white border border-stone-200 rounded-xl px-4 py-3 text-stone-800 font-bold text-xs focus:border-stone-900 focus:outline-none transition-all resize-none shadow-sm"
|
||
rows={2}
|
||
/>
|
||
</div>
|
||
|
||
{/* Parameters Grid */}
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||
{/* Product Type */}
|
||
<div>
|
||
<label className="text-[10px] font-black uppercase tracking-widest text-stone-400 mb-2 block">Product Type</label>
|
||
<select
|
||
value={productType}
|
||
onChange={(e) => setProductType(e.target.value as ProductType)}
|
||
className="w-full bg-white border border-stone-200 text-stone-700 px-3 py-2 rounded-lg text-xs font-bold focus:ring-1 focus:ring-stone-300 cursor-pointer outline-none shadow-sm"
|
||
>
|
||
{["Wall Art", "Sticker", "Planner", "Bookmark", "Label"].map(t => <option key={t} value={t}>{t}</option>)}
|
||
</select>
|
||
</div>
|
||
|
||
{/* Sticker Set Configuration (Blueprint) */}
|
||
{productType === "Sticker" && (
|
||
<div className="bg-purple-50/50 p-3 rounded-xl border border-purple-100 flex flex-col justify-center gap-2">
|
||
<div className="flex items-center justify-between">
|
||
<label className="text-[10px] font-black uppercase tracking-widest text-purple-900/70">Sticker Set</label>
|
||
<div className={`w-8 h-4 rounded-full p-0.5 cursor-pointer transition-colors relative ${isStickerSet ? 'bg-purple-500' : 'bg-stone-300'}`} onClick={() => setIsStickerSet(!isStickerSet)}>
|
||
<div className={`w-3 h-3 bg-white rounded-full shadow-sm transition-transform ${isStickerSet ? 'translate-x-4' : 'translate-x-0'}`} />
|
||
</div>
|
||
</div>
|
||
|
||
{isStickerSet && (
|
||
<div className="flex items-center gap-2">
|
||
<select
|
||
value={setSize}
|
||
onChange={(e) => setSetSize(e.target.value)}
|
||
className="w-full bg-white border border-purple-200 text-purple-900 text-xs font-bold rounded-lg px-2 py-1.5 outline-none focus:ring-1 focus:ring-purple-400 cursor-pointer"
|
||
>
|
||
{[6, 9, 12, 16, 20, 25].map(n => <option key={n} value={n}>{n} Variations</option>)}
|
||
</select>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Aspect Ratio */}
|
||
<div>
|
||
<label className="text-[10px] font-black uppercase tracking-widest text-stone-400 mb-2 block flex justify-between">
|
||
<span>Orientation / Ratio</span>
|
||
<span className="text-stone-900">{aspectRatio}</span>
|
||
</label>
|
||
<div className="flex items-center gap-2">
|
||
<select
|
||
value={selectedRatio}
|
||
onChange={(e) => {
|
||
const r = e.target.value as AspectRatio;
|
||
setSelectedRatio(r);
|
||
setAspectRatio(r);
|
||
}}
|
||
className="w-full bg-white border border-stone-200 text-stone-700 px-3 py-2 rounded-lg text-xs font-bold focus:ring-1 focus:ring-stone-300 cursor-pointer outline-none appearance-none shadow-sm"
|
||
>
|
||
{productType === "Sticker" ? (
|
||
// STICKER MODE: Only Paper Sizes
|
||
["A4", "A5", "Letter"].map(r => (
|
||
<option key={r} value={r}>{r} (Paper)</option>
|
||
))
|
||
) : (
|
||
// STANDARD MODE: All Ratios
|
||
["1:1", "3:4", "4:3", "2:3", "3:2", "9:16", "16:9", "4:5", "5:4", "A4", "Letter"].map(r => (
|
||
<option key={r} value={r}>{r} {['A4', 'Letter', 'A5'].includes(r) ? '(Paper)' : r === '1:1' ? '(Square)' : (parseInt(r.split(':')[0]) > parseInt(r.split(':')[1]) ? '(Landscape)' : '(Portrait)')}</option>
|
||
))
|
||
)}
|
||
</select>
|
||
{(() => {
|
||
const parts = aspectRatio.split(':');
|
||
let w = parseFloat(parts[0]);
|
||
let h = parseFloat(parts[1]);
|
||
|
||
if (aspectRatio === 'A4' || aspectRatio === 'A5') { w = 1; h = 1.414; }
|
||
if (aspectRatio === 'Letter') { w = 8.5; h = 11; }
|
||
|
||
if (isNaN(w)) w = 1;
|
||
if (isNaN(h)) h = 1;
|
||
|
||
// Handle 2.35:1
|
||
if (aspectRatio === '2.35:1') { w = 2.35; h = 1; }
|
||
|
||
const ratio = w / h;
|
||
// Base height 24px
|
||
const baseH = 24;
|
||
const baseW = baseH * ratio;
|
||
|
||
return (
|
||
<div className="h-8 w-14 flex items-center justify-center bg-stone-100 rounded border border-stone-200 ml-2" title={`Preview: ${aspectRatio}`}>
|
||
<div
|
||
style={{
|
||
width: `${Math.min(baseW, 40)}px`,
|
||
height: `${Math.min(baseW, 40) / ratio}px`,
|
||
maxHeight: '24px',
|
||
maxWidth: '40px'
|
||
}}
|
||
className="bg-stone-800 rounded-sm shadow-sm transition-all duration-300"
|
||
/>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Creativity */}
|
||
<div>
|
||
<label className="text-[10px] font-black uppercase tracking-widest text-stone-400 mb-2 block flex justify-between">
|
||
<span>Creative Freedom</span>
|
||
<span className="text-stone-900">{creativity}</span>
|
||
</label>
|
||
<div className="flex flex-col gap-1.5 pt-1">
|
||
<input
|
||
type="range"
|
||
min="0" max="2" step="1"
|
||
value={creativity === "Conservative" ? 0 : creativity === "Balanced" ? 1 : 2}
|
||
onChange={(e) => setCreativity(e.target.value === "0" ? "Conservative" : e.target.value === "1" ? "Balanced" : "Wild")}
|
||
className="w-full accent-stone-900 cursor-pointer h-2 bg-stone-200 rounded-full appearance-none"
|
||
/>
|
||
<div className="flex justify-between text-[8px] font-bold uppercase tracking-widest text-stone-300">
|
||
<span>Strict</span>
|
||
<span>Balanced</span>
|
||
<span>Wild</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Quality & DNA */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 items-start">
|
||
{/* Quality */}
|
||
<div className="bg-white rounded-xl border border-stone-200 p-4 shadow-sm h-full">
|
||
<label className="text-[10px] font-black uppercase tracking-widest text-stone-400 mb-3 block">Draft Quality</label>
|
||
<div className="grid grid-cols-3 gap-2">
|
||
{["SD", "HD", "4K"].map((q) => (
|
||
<button
|
||
key={q}
|
||
onClick={() => setSelectedSize(q as ImageSize)}
|
||
className={`p-2 rounded-xl border-2 transition-all flex flex-col items-center gap-0.5 ${selectedSize === q ? 'border-stone-900 bg-stone-50' : 'border-transparent bg-stone-50/50 hover:bg-stone-100'}`}
|
||
>
|
||
<span className={`text-[10px] font-black ${selectedSize === q ? 'text-stone-900' : 'text-stone-400'}`}>{q}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Visual DNA Management */}
|
||
<div className="bg-white rounded-xl border border-stone-200 p-4 shadow-sm h-full">
|
||
<div className="flex flex-col gap-4">
|
||
<div className="flex items-center justify-between">
|
||
<label className="text-[10px] font-black uppercase tracking-widest text-stone-400 flex items-center gap-2">
|
||
Visual DNA <span className="bg-stone-100 px-1.5 rounded text-[9px] font-bold">{referenceImages.length}</span>
|
||
</label>
|
||
|
||
<button
|
||
onClick={() => setUseExactReference(!useExactReference)}
|
||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-wider transition-all border ${useExactReference
|
||
? 'bg-indigo-50 text-indigo-600 border-indigo-200'
|
||
: 'bg-white text-stone-300 border-stone-100 hover:text-stone-500'
|
||
}`}
|
||
>
|
||
<span className="text-sm">{useExactReference ? '📐 Structure' : '🎨 Style'}</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<select
|
||
value={activeDnaProfile?.id || ""}
|
||
onChange={(e) => e.target.value === "" ? (setActiveDnaProfile(null), setReferenceImages([])) : loadDnaProfile(e.target.value)}
|
||
className="bg-stone-50 border border-stone-200 text-stone-600 text-[10px] font-bold rounded-lg px-2 py-2 outline-none focus:border-stone-400 cursor-pointer uppercase flex-1 min-w-[150px]"
|
||
>
|
||
<option value="">📂 {activeDnaProfile ? 'Unsaved / New' : 'Load DNA...'}</option>
|
||
{dnaProfiles.map((p: any) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||
</select>
|
||
|
||
{referenceImages.length > 0 && (
|
||
<div className="flex items-center gap-1.5">
|
||
{activeDnaProfile && (
|
||
<>
|
||
<button onClick={async () => {
|
||
if (!window.confirm(`Update '${activeDnaProfile.name}'?`)) return;
|
||
await axios.post('/api/profiles', { name: activeDnaProfile.name, referenceImages, overwrite: true });
|
||
const r = await axios.get('/api/profiles'); setDnaProfiles(r.data.profiles);
|
||
}} className="text-emerald-600 bg-emerald-50 px-2.5 py-2 rounded-lg border border-emerald-100">💾</button>
|
||
<button onClick={() => handleDeleteDnaProfile(activeDnaProfile.id)} className="text-red-500 bg-red-50 px-2.5 py-2 rounded-lg border border-red-100">🗑️</button>
|
||
</>
|
||
)}
|
||
<button onClick={async () => {
|
||
const name = prompt("Name this DNA Profile:");
|
||
if (!name) return;
|
||
await axios.post('/api/profiles', { name, referenceImages });
|
||
const r = await axios.get('/api/profiles'); setDnaProfiles(r.data.profiles);
|
||
}} className="bg-indigo-600 text-white px-4 py-2 rounded-lg font-black text-[10px] uppercase">{activeDnaProfile ? 'Copy' : 'Save DNA'}</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||
{referenceImages.map((src, i) => (
|
||
<div key={i} className="relative w-12 h-12 shrink-0 group">
|
||
<img src={src} className="w-full h-full object-cover rounded-lg border border-stone-200" />
|
||
<button onClick={() => setReferenceImages(p => p.filter((_, idx) => idx !== i))} className="absolute -top-1 -right-1 bg-red-500 text-white w-4 h-4 rounded-full flex items-center justify-center text-[10px] opacity-0 group-hover:opacity-100">×</button>
|
||
</div>
|
||
))}
|
||
<div onClick={() => document.getElementById('wizard_dna_upload')?.click()} className="w-12 h-12 shrink-0 border border-dashed border-stone-300 rounded-lg flex items-center justify-center cursor-pointer hover:bg-stone-50 text-stone-300">
|
||
<span className="text-xl">+</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Action Buttons */}
|
||
<div className="flex justify-end gap-3 pt-4">
|
||
<button
|
||
onClick={() => window.confirm("Reroll with these Blueprint settings?") && handleCreateProject()}
|
||
disabled={isLoading}
|
||
className="bg-stone-900 text-white px-8 py-3 rounded-xl font-black uppercase tracking-widest text-[10px] hover:scale-105 transition-transform disabled:opacity-50 shadow-xl"
|
||
>
|
||
{isLoading ? 'Rerolling...' : '🚀 Reroll from Blueprint'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
|
||
<div className="grid grid-cols-1 xl:grid-cols-12 gap-8">
|
||
|
||
{/* LEFT COLUMN: Art Director Station */}
|
||
<div className="xl:col-span-6 space-y-8">
|
||
<section className="bg-white rounded-[3rem] p-8 shadow-2xl border border-stone-100 relative overflow-hidden">
|
||
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-purple-500 via-pink-500 to-yellow-500" />
|
||
|
||
<div className="flex justify-between items-center mb-8">
|
||
<h3 className="font-black text-2xl tracking-tighter text-stone-900 flex items-center gap-2">
|
||
<span className="text-3xl">🎨</span> Art Director Station
|
||
</h3>
|
||
</div>
|
||
|
||
|
||
|
||
{/* ACTIVE MASTER CANVAS */}
|
||
{(() => {
|
||
// FALLBACK: Select any visible asset if no specific ID is active
|
||
const visibleAssets = projectData.assets?.filter((a: any) => ['master', 'upscaled', 'revision'].includes(a.type))
|
||
.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) || [];
|
||
|
||
const activeAsset = projectData.assets?.find((a: any) => a.id === activeAssetId) || visibleAssets[0];
|
||
|
||
if (!activeAsset) return (
|
||
<div className="aspect-[3/4] bg-stone-900 rounded-[3rem] flex items-center justify-center border border-white/10">
|
||
<p className="text-white/20 font-black text-2xl uppercase tracking-widest">No Assets Found</p>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div className="relative group">
|
||
<div className="absolute top-6 left-6 z-10 flex flex-col gap-2">
|
||
{(() => {
|
||
const isUpscaled = activeAsset.type === 'upscaled';
|
||
const displayQuality = isUpscaled ? 'MASTER' : (activeAsset.quality || 'MASTER');
|
||
const isDraft = displayQuality === 'DRAFT';
|
||
|
||
return (
|
||
<span className={`px-4 py-1.5 rounded-full text-[10px] font-black uppercase tracking-widest backdrop-blur-md border shadow-2xl ${isDraft ? 'bg-amber-400/90 text-amber-900 border-amber-300' : 'bg-purple-500/90 text-white border-white/20'}`}>
|
||
{displayQuality} QUALITY
|
||
</span>
|
||
);
|
||
})()}
|
||
</div>
|
||
|
||
|
||
|
||
{/* DELETE BUTTON FOR MASTER/DRAFT */}
|
||
<button
|
||
type="button"
|
||
onMouseDown={(e) => {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
setIsDeleteDialogOpen(true);
|
||
}}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
// Delay confirm to next tick so ZoomableImage sees disabled=true
|
||
setTimeout(() => {
|
||
const confirmDelete = window.confirm(`Delete this ${activeAsset.type === 'upscaled' ? 'upscaled' : activeAsset.quality || 'master'} image?`);
|
||
setIsDeleteDialogOpen(false);
|
||
if (confirmDelete) {
|
||
handleDeleteAsset(activeAsset.id, activeAsset.type);
|
||
}
|
||
}, 10);
|
||
}}
|
||
className="absolute top-6 right-6 z-30 bg-red-500 hover:bg-red-600 text-white w-10 h-10 rounded-full flex items-center justify-center text-lg font-bold opacity-0 group-hover:opacity-100 transition-all shadow-xl hover:scale-110"
|
||
title="Delete this image"
|
||
>
|
||
🗑️
|
||
</button>
|
||
|
||
{/* LEGACY ZIP DOWNLOADS (The "Real" 3 Links) */}
|
||
|
||
|
||
|
||
<ZoomableImage
|
||
src={`/storage/${activeAsset.path}`}
|
||
alt="Master Asset"
|
||
className={`w-full rounded-[3rem] shadow-2xl border-4 ${activeAsset.quality === 'DRAFT' ? 'border-amber-400/50' : 'border-purple-500/30'}`}
|
||
disabled={isDeleteDialogOpen}
|
||
/>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* VISUAL HISTORY STRIP (Moved) */}
|
||
{projectData.assets && projectData.assets.length > 0 && (
|
||
<div className="flex gap-4 overflow-x-auto pb-6 pt-2 snap-x mt-8 border-t border-stone-100 pt-8">
|
||
{projectData.assets.filter((a: any) => ['master', 'revision', 'upscaled'].includes(a.type)).sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()).map((asset: any) => (
|
||
<div key={asset.id} className="relative group/item flex-none w-24 h-32">
|
||
<button
|
||
onClick={() => setActiveAssetId(asset.id)}
|
||
className={`w-full h-full rounded-2xl overflow-hidden border-2 transition-all bg-stone-100 ${activeAssetId === asset.id ? 'border-purple-500 scale-105 shadow-lg' : 'border-stone-200 hover:border-stone-400'}`}
|
||
>
|
||
<img src={`/storage/${asset.path}`} className="w-full h-full object-contain" />
|
||
<div className={`absolute bottom-0 w-full text-[8px] font-black text-center py-1 uppercase ${asset.quality === 'MASTER' ? 'bg-purple-600 text-white' : 'bg-stone-800 text-white'}`}>
|
||
{asset.quality || 'MASTER'}
|
||
</div>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onMouseDown={(e) => {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
setTimeout(() => {
|
||
const confirmDelete = window.confirm('Delete this version?');
|
||
if (confirmDelete) {
|
||
handleDeleteAsset(asset.id, asset.type);
|
||
}
|
||
}, 0);
|
||
}}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
}}
|
||
className="absolute -top-2 -right-2 bg-red-500 text-white w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold opacity-0 group-hover/item:opacity-100 transition-opacity shadow-lg scale-90 hover:scale-110 z-20"
|
||
title="Delete Version"
|
||
>
|
||
🗑️
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* REFINE CONTROLS */}
|
||
<div id="refine-section" className="mt-8 bg-stone-50 p-6 rounded-3xl border border-stone-100">
|
||
{/* Display Previous Prompt if available */}
|
||
{(() => {
|
||
const currentAsset = projectData?.assets?.find((a: any) => a.id === (activeAssetId || projectData.assets[0]?.id));
|
||
if (!currentAsset?.meta) return null;
|
||
try {
|
||
const meta = typeof currentAsset.meta === 'string' ? JSON.parse(currentAsset.meta) : currentAsset.meta;
|
||
if (meta.brief) {
|
||
return (
|
||
<div className="mb-4 bg-white p-4 rounded-2xl border border-purple-100 shadow-sm relative overflow-hidden group">
|
||
<div className="absolute top-0 left-0 w-1 h-full bg-purple-400 group-hover:h-full transition-all"></div>
|
||
<div className="text-[9px] font-black uppercase tracking-widest text-purple-400 mb-1 flex items-center gap-2">
|
||
<span className="w-1.5 h-1.5 rounded-full bg-purple-500 animate-pulse"></span>
|
||
Generated with Prompt
|
||
</div>
|
||
<p className="text-xs text-stone-700 font-medium italic leading-relaxed pl-2 border-l-2 border-transparent">
|
||
"{meta.brief}"
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
} catch (e) { return null; }
|
||
return null;
|
||
})()}
|
||
|
||
<label className="text-[10px] font-black uppercase tracking-widest text-stone-400 mb-4 block">Refine & iterate</label>
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="text"
|
||
value={revisionBrief}
|
||
onChange={(e) => setRevisionBrief(e.target.value)}
|
||
placeholder="e.g., 'Make the colors warmer', 'Remove the tree'..."
|
||
className="flex-1 bg-white border border-stone-200 rounded-xl px-4 py-3 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-stone-900"
|
||
/>
|
||
<button
|
||
onClick={handleRefine}
|
||
disabled={!revisionBrief || isRefining}
|
||
className="bg-stone-900 text-white px-6 rounded-xl font-bold uppercase text-xs tracking-widest hover:bg-stone-800 disabled:opacity-50 flex items-center gap-2 group"
|
||
>
|
||
{isRefining ? (
|
||
<>
|
||
<Loader2 className="w-3 h-3 animate-spin" />
|
||
<span className="animate-pulse">Refining...</span>
|
||
</>
|
||
) : 'Refine'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
{/* NEURO-SCORE PANEL (Relocated) */}
|
||
<div className="mt-6 p-6 bg-stone-50 rounded-3xl border border-stone-100">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h4 className="font-black text-sm uppercase tracking-widest text-purple-900 flex items-center gap-2">
|
||
<BrainCircuit className="w-4 h-4 text-purple-600" />
|
||
Neuro-Score Analysis
|
||
</h4>
|
||
<button
|
||
onClick={() => {
|
||
const targetId = activeAssetId || projectData.assets?.[0]?.id;
|
||
if (targetId) handleNeuroAnalyze(targetId);
|
||
}}
|
||
disabled={isAnalyzing}
|
||
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white text-[10px] font-black uppercase rounded-xl transition-all shadow-lg hover:shadow-purple-200 disabled:opacity-50 flex items-center gap-2"
|
||
>
|
||
{isAnalyzing ? <Loader2 className="w-3 h-3 animate-spin" /> : <BrainCircuit className="w-3 h-3" />}
|
||
{neuroAnalysis ? 'Re-Analyze' : 'Analyze Visual DNA'}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Inline Scorecard Container (Dark Theme Wrapper) */}
|
||
{(isAnalyzing || neuroAnalysis) && (
|
||
<div className="mt-4 bg-stone-900 rounded-3xl overflow-hidden shadow-inner border border-stone-800">
|
||
<NeuroScorecard
|
||
analysis={neuroAnalysis}
|
||
loading={isAnalyzing}
|
||
onApplyImprovement={(fix) => {
|
||
const modal = document.getElementById('neuro-modal-overlay');
|
||
if (modal) modal.classList.add('opacity-0', 'pointer-events-none');
|
||
handleApplyNeuroFix(fix);
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* UPSCALE MASTER BUTTON */}
|
||
<div className="mt-6 p-6 bg-gradient-to-r from-emerald-50 to-green-50 rounded-3xl border border-emerald-200">
|
||
<div className="flex flex-col gap-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h4 className="font-black text-sm uppercase tracking-widest text-emerald-800 mb-1">🎯 Finalize for Print</h4>
|
||
<p className="text-xs text-emerald-600">
|
||
{upscaledAsset
|
||
? `✅ Ready: ${upscaledAsset.printSize} @ 300 DPI`
|
||
: 'Upscale to 6000px @ 300 DPI'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Ratio Override Selector */}
|
||
<div className="flex items-center gap-4">
|
||
<div className="flex-1">
|
||
<label className="text-[10px] font-black uppercase tracking-widest text-emerald-700 mb-1 block">Target Ratio</label>
|
||
<select
|
||
value={upscaleRatioOverride}
|
||
onChange={(e) => setUpscaleRatioOverride(e.target.value)}
|
||
className="w-full bg-white border border-emerald-200 text-emerald-800 px-3 py-2 rounded-xl text-xs font-bold focus:ring-2 focus:ring-emerald-400 cursor-pointer outline-none"
|
||
disabled={!!upscaledAsset}
|
||
>
|
||
<option value="auto">📐 Auto (Project: {projectData?.project?.aspectRatio || '3:4'})</option>
|
||
<option value="9:16">9:16 → 3375×6000px (Phone Wallpaper)</option>
|
||
<option value="3:4">3:4 → 4500×6000px (Standard Portrait)</option>
|
||
<option value="4:5">4:5 → 4800×6000px (Instagram / 8x10)</option>
|
||
<option value="2:3">2:3 → 4000×6000px (4x6, 24x36)</option>
|
||
<option value="1:1">1:1 → 6000×6000px (Square)</option>
|
||
<option value="16:9">16:9 → 6000×3375px (Wide)</option>
|
||
<option value="A4">A4 (ISO) → 4961×7016px (Print)</option>
|
||
<option value="Letter">Letter (US) → 4636×6000px (Print)</option>
|
||
<option value="A5">A5 (ISO) → 4243×6000px (Journal)</option>
|
||
</select>
|
||
</div>
|
||
<button
|
||
onClick={() => {
|
||
const visibleAssets = projectData.assets?.filter((a: any) => ['master', 'upscaled', 'revision'].includes(a.type))
|
||
.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) || [];
|
||
|
||
let rawId = activeAssetId || visibleAssets[0]?.id;
|
||
const targetId = rawId ? rawId.replace('lightbox_', '') : null;
|
||
|
||
if (!targetId) return alert("Please select an image to upscale.");
|
||
|
||
setIsUpscaling(true);
|
||
const overrideRatio = upscaleRatioOverride === 'auto' ? undefined : upscaleRatioOverride;
|
||
|
||
axios.post(`/api/projects/${projectData.project.id}/upscale`, {
|
||
assetId: targetId,
|
||
overrideRatio: overrideRatio
|
||
})
|
||
.then(() => handleSelectProject(projectData.project.id))
|
||
.catch(e => {
|
||
console.error("[Client] Upscale Error:", e);
|
||
alert("Upscale failed: " + (e.response?.data?.error || e.message));
|
||
})
|
||
.finally(() => setIsUpscaling(false));
|
||
}}
|
||
disabled={isUpscaling || !!upscaledAsset}
|
||
className="bg-emerald-600 text-white px-8 py-4 rounded-2xl font-black uppercase tracking-widest hover:bg-emerald-500 transition-all disabled:opacity-50 shadow-lg hover:shadow-emerald-300"
|
||
>
|
||
{isUpscaling ? '⏳ Upscaling...' : upscaledAsset ? '✅ Done' : '🚀 Upscale'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{upscaledAsset && (
|
||
<div className="flex flex-wrap gap-2 mt-4">
|
||
<a
|
||
href={`/storage/${upscaledAsset.path}`}
|
||
download
|
||
className="inline-block bg-emerald-100 text-emerald-700 px-6 py-3 rounded-xl text-xs font-bold hover:bg-emerald-200 transition-all border border-emerald-200"
|
||
>
|
||
⬇ Download Print-Ready Master ({upscaledAsset.width}×{upscaledAsset.height}px)
|
||
</a>
|
||
|
||
<button
|
||
onClick={() => handleDownloadCricut(upscaledAsset.id)}
|
||
className="inline-block bg-emerald-900 text-emerald-100 px-6 py-3 rounded-xl text-xs font-bold hover:bg-emerald-800 transition-all border border-emerald-700 shadow-md flex items-center gap-2"
|
||
>
|
||
<span>✂️</span> Download Cricut Pack (ZIP)
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
{/* VARIANTS GRID */}
|
||
<section className="bg-white p-8 rounded-[3rem] shadow-sm border border-stone-100 mt-8">
|
||
<div className="flex justify-between items-center mb-8">
|
||
<h3 className="font-black text-2xl tracking-tighter text-stone-900 flex items-center gap-2">
|
||
<span className="text-3xl">🧩</span> Variants & Explorations
|
||
</h3>
|
||
|
||
<div className="flex items-center gap-3">
|
||
{/* Variant Fill Selector */}
|
||
<select
|
||
value={variantFillType}
|
||
onChange={(e) => setVariantFillType(e.target.value)}
|
||
className="bg-stone-50 border border-stone-200 text-stone-600 text-[10px] font-bold uppercase tracking-widest px-3 py-2 rounded-xl cursor-pointer outline-none focus:ring-2 focus:ring-purple-400"
|
||
disabled={isGeneratingVariants}
|
||
title="Fallback fill color if AI Outpainting fails"
|
||
>
|
||
<option value="auto">🔮 AI Seamless / Auto</option>
|
||
<option value="white">⬜ White</option>
|
||
<option value="black">⬛ Black</option>
|
||
<option value="blur">💧 Blur</option>
|
||
</select>
|
||
|
||
{/* Generate Variants Button - HIDDEN for Sticker Sets */}
|
||
{(!projectData?.project?.config || !JSON.parse(projectData.project.config).isStickerSet) && (
|
||
<button
|
||
onClick={handleRegenerateVariants}
|
||
className="bg-stone-900 hover:bg-stone-800 text-white px-5 py-2.5 rounded-full text-xs font-black uppercase tracking-widest transition-colors flex items-center gap-2 shadow-lg shadow-purple-200/50"
|
||
disabled={isGeneratingVariants}
|
||
>
|
||
{isGeneratingVariants ? <Loader2 className="w-3 h-3 animate-spin" /> : (variants.length > 0 ? '↻ New Set' : 'Generate Variants')}
|
||
</button>
|
||
)}
|
||
|
||
{/* Sticker Sheet Button */}
|
||
{projectData?.project?.config && JSON.parse(projectData.project.config).isStickerSet && (
|
||
<button
|
||
onClick={handleGenerateStickerSheet}
|
||
className="bg-emerald-600 hover:bg-emerald-700 text-white px-5 py-2.5 rounded-full text-xs font-black uppercase tracking-widest transition-colors flex items-center gap-2 shadow-lg shadow-emerald-200/50"
|
||
disabled={isLoading}
|
||
>
|
||
{isLoading && loadingMsg.includes('Compositing') ? <Loader2 className="w-3 h-3 animate-spin" /> : '📄 Generate Sheet'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{variants.length === 0 && !isGeneratingVariants ? (
|
||
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-stone-200 rounded-3xl text-stone-400">
|
||
<span className="text-4xl mb-4">✨</span>
|
||
<p className="font-bold uppercase tracking-widest text-xs">No variants generated yet</p>
|
||
<button
|
||
onClick={handleRegenerateVariants}
|
||
className="mt-4 text-purple-600 underline text-xs font-black uppercase tracking-widest"
|
||
>
|
||
Generate Now
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||
{variants.map((v: any, i) => {
|
||
const meta = v.meta ? JSON.parse(v.meta) : {};
|
||
const label = v.label || meta.label || `Variant ${i + 1}`;
|
||
const ratio = v.ratio || meta.ratio || 'Unknown';
|
||
|
||
// LIGHTBOX STATE
|
||
const isExpanded = activeAssetId === `lightbox_${v.id}`;
|
||
|
||
return (
|
||
<div
|
||
key={v.id || i}
|
||
className={`relative group cursor-pointer w-full bg-stone-50 rounded-2xl overflow-hidden border border-stone-100 shadow-sm`}
|
||
onClick={() => setActiveAssetId(isExpanded ? null : `lightbox_${v.id}`)}
|
||
>
|
||
{/* NORMAL CARD */}
|
||
<div className={`relative w-full aspect-[3/4] flex items-center justify-center`}>
|
||
<img
|
||
src={`/storage/${v.path}?t=${new Date().getTime()}`}
|
||
className="w-full h-full object-contain"
|
||
/>
|
||
|
||
<div className="absolute bottom-0 left-0 right-0 bg-stone-900/60 p-2 backdrop-blur-sm flex justify-between items-center">
|
||
<span className="text-[10px] font-black text-white uppercase tracking-wider">{label}</span>
|
||
<span className="text-[9px] font-mono text-stone-300">{ratio}</span>
|
||
</div>
|
||
|
||
{/* HOVER ACTIONS */}
|
||
<div className="absolute inset-0 bg-stone-900/90 p-4 flex flex-col justify-center items-center opacity-0 group-hover:opacity-100 transition-opacity text-center gap-2"
|
||
onMouseEnter={() => handleVariantHover(v.id)}
|
||
>
|
||
{variantMetadata[v.id] ? (
|
||
<div className="text-[10px] text-stone-300 font-mono mb-2 flex flex-col gap-1">
|
||
<span className="text-white font-black">{variantMetadata[v.id].width} x {variantMetadata[v.id].height} px</span>
|
||
<span className="uppercase text-amber-400">{variantMetadata[v.id].space}</span>
|
||
<span>{(variantMetadata[v.id].size / 1024 / 1024).toFixed(2)} MB</span>
|
||
</div>
|
||
) : (
|
||
<span className="text-[10px] text-stone-500 animate-pulse">Inspecting...</span>
|
||
)}
|
||
|
||
<span className="text-xl mb-1">🔍</span>
|
||
<span className="text-[9px] text-white/50 uppercase tracking-widest">Zoom</span>
|
||
|
||
<div className="flex gap-2 mt-2">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleDeleteAsset(v.id, 'variant');
|
||
setVariants(prev => prev.filter(item => item.id !== v.id));
|
||
}}
|
||
className="p-2 bg-red-500/20 hover:bg-red-500 text-red-200 hover:text-white rounded-full transition-colors"
|
||
title="Delete Variant"
|
||
>
|
||
<span className="text-xs">🗑️</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* LIGHTBOX OVERLAY */}
|
||
{isExpanded && (
|
||
<div
|
||
className="fixed inset-0 z-[9999] bg-stone-900/95 backdrop-blur-md flex items-center justify-center p-8 cursor-zoom-out"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setActiveAssetId(null);
|
||
}}
|
||
>
|
||
<img
|
||
src={`/storage/${v.path}?t=${new Date().getTime()}`}
|
||
className="max-w-full max-h-full rounded-2xl shadow-2xl animate-in fade-in zoom-in duration-200"
|
||
/>
|
||
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 flex gap-4">
|
||
<a
|
||
href={`/storage/${v.path}`}
|
||
download
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="bg-white text-stone-900 px-6 py-3 rounded-full font-black uppercase tracking-widest hover:scale-105 transition-transform"
|
||
>
|
||
Download Original
|
||
</a>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
alert(`Regenerating ${label}...`);
|
||
}}
|
||
className="bg-purple-600 text-white px-6 py-3 rounded-full font-black uppercase tracking-widest hover:scale-105 transition-transform"
|
||
>
|
||
Regenerate This Variant ↻
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{/* MOCKUP STUDIO SECTION */}
|
||
<section className="bg-stone-900 rounded-[3rem] p-8 shadow-2xl relative overflow-hidden text-white">
|
||
<div className="relative z-10">
|
||
{/* Mockup Controls Toolbar */}
|
||
{/* Mockup Controls Toolbar - ELEGANT & COMPACT */}
|
||
<div className="bg-white/5 backdrop-blur-xl p-5 rounded-2xl border border-white/10 shadow-xl relative overflow-hidden mb-6">
|
||
<div className="absolute inset-0 bg-gradient-to-br from-indigo-500/5 via-transparent to-purple-500/5 pointer-events-none" />
|
||
|
||
<div className="relative z-10 flex flex-col lg:flex-row items-end gap-4">
|
||
{/* Selectors Grid - TWO COLUMN STACKED */}
|
||
<div className="flex-1 grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||
{/* Column 1: Scenario & Atmosphere */}
|
||
<div className="flex flex-col gap-4">
|
||
{/* Scenario */}
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-bold uppercase tracking-[0.15em] text-white/40 block px-0.5">Scenario</label>
|
||
<select
|
||
value={mockupScenario}
|
||
onChange={(e) => setMockupScenario(e.target.value)}
|
||
className="w-full bg-stone-950/40 border border-white/5 text-white text-[11px] font-medium rounded-xl px-3 py-3 outline-none focus:ring-1 focus:ring-indigo-500/50 transition-all cursor-pointer appearance-none hover:bg-stone-900/60"
|
||
>
|
||
{(() => {
|
||
let pType = projectData?.project?.productType || "Wall Art";
|
||
if (pType.includes("Planner")) pType = "Planner";
|
||
else if (pType.includes("Sticker")) pType = "Sticker";
|
||
else if (pType.includes("Social")) pType = "Social Media Kit";
|
||
else if (pType.includes("Phone")) pType = "Phone Wallpaper";
|
||
else if (pType.includes("Bookmark")) pType = "Bookmark";
|
||
else if (pType.includes("Label")) pType = "Label";
|
||
else pType = "Wall Art";
|
||
|
||
// Merge with Custom
|
||
const scenarios = {
|
||
...(CLIENT_MOCKUP_SCENARIOS[pType] || CLIENT_MOCKUP_SCENARIOS["Wall Art"]),
|
||
...CLIENT_MOCKUP_SCENARIOS["Custom"]
|
||
};
|
||
return Object.entries(scenarios).map(([key, label]) => (
|
||
<option key={key} value={key} className="bg-stone-900 text-white">{label}</option>
|
||
));
|
||
})()}
|
||
</select>
|
||
</div>
|
||
|
||
{/* CUSTOM PROMPT INPUT */}
|
||
{mockupScenario === 'custom' && (
|
||
<div className="animate-fade-in mt-2">
|
||
<input
|
||
type="text"
|
||
value={customMockupPrompt}
|
||
onChange={(e) => setCustomMockupPrompt(e.target.value)}
|
||
placeholder="Describe your scene (e.g., 'Sticker on a red skateboard')..."
|
||
className="w-full bg-stone-950/40 border border-indigo-500/50 text-white text-[10px] font-medium rounded-xl px-3 py-2 outline-none focus:ring-2 focus:ring-indigo-500 transition-all placeholder:text-white/20"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Atmosphere */}
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-bold uppercase tracking-[0.15em] text-white/40 block px-0.5">Atmosphere</label>
|
||
<select
|
||
value={mockupAtmosphere}
|
||
onChange={(e) => setMockupAtmosphere(e.target.value)}
|
||
className="w-full bg-stone-950/40 border border-white/5 text-white text-[11px] font-medium rounded-xl px-3 py-3 outline-none focus:ring-1 focus:ring-indigo-500/50 transition-all cursor-pointer appearance-none hover:bg-stone-900/60"
|
||
>
|
||
{Object.entries(CLIENT_ATMOSPHERES).map(([key, label]) => (
|
||
<option key={key} value={key} className="bg-stone-900 text-white">{label}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Column 2: Ratio & Watermark */}
|
||
<div className="flex flex-col gap-4">
|
||
{/* Ratio */}
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-bold uppercase tracking-[0.15em] text-white/40 block px-0.5">Ratio</label>
|
||
<select
|
||
value={mockupRatio}
|
||
onChange={(e) => setMockupRatio(e.target.value)}
|
||
className="w-full bg-stone-950/40 border border-white/5 text-white text-[11px] font-medium rounded-xl px-3 py-3 outline-none focus:ring-1 focus:ring-indigo-500/50 transition-all cursor-pointer appearance-none hover:bg-stone-900/60"
|
||
>
|
||
{CLIENT_ASPECT_RATIOS.map((r) => (
|
||
<option key={r} value={r} className="bg-stone-900 text-white">{r}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{/* Watermark Toggle */}
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-bold uppercase tracking-[0.15em] text-white/40 block px-0.5">Watermark</label>
|
||
<button
|
||
onClick={() => {
|
||
// Check if user has a logo uploaded
|
||
if (!useWatermark && !user?.etsyShopLogo) {
|
||
alert('⚠️ Logo Required!\n\nTo use watermarks, please upload your logo first in Settings > Store Branding.');
|
||
return;
|
||
}
|
||
setUseWatermark(!useWatermark);
|
||
}}
|
||
className={`w-full flex items-center justify-between px-3 py-3 rounded-xl border transition-all ${useWatermark
|
||
? 'bg-indigo-500/20 border-indigo-500/50 text-indigo-200'
|
||
: 'bg-stone-950/40 border-white/5 text-white/30 hover:bg-stone-900/60'}`}
|
||
>
|
||
<span className="text-[10px] font-bold uppercase">{useWatermark ? 'Enabled' : 'Disabled'}</span>
|
||
<div className={`w-6 h-3 rounded-full relative transition-colors ${useWatermark ? 'bg-indigo-500' : 'bg-stone-700'}`}>
|
||
<div className={`absolute top-0.5 w-2 h-2 rounded-full bg-white transition-transform ${useWatermark ? 'left-[14px]' : 'left-0.5'}`} />
|
||
</div>
|
||
</button>
|
||
{!user?.etsyShopLogo && (
|
||
<p className="text-[9px] text-amber-400/70 px-1">
|
||
⚠️ No logo uploaded. Go to <a href="/settings" className="underline hover:text-amber-300">Settings</a>.
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Actions Station */}
|
||
<div className="flex flex-col gap-2 min-w-[120px]">
|
||
<CreditButton
|
||
cost={1}
|
||
onClick={handleGenerateSingleMockup}
|
||
disabled={isGeneratingMockups}
|
||
className="w-full bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 text-white px-5 py-3 rounded-xl text-[10px] font-black uppercase tracking-widest shadow-lg transition-all disabled:opacity-50 flex items-center justify-center gap-2"
|
||
>
|
||
{isGeneratingMockups ? <Loader2 className="w-3 h-3 animate-spin" /> : 'Create'}
|
||
</CreditButton>
|
||
<button
|
||
onClick={handleGenerateMockups}
|
||
disabled={isGeneratingMockups}
|
||
className="w-full px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-white/40 hover:text-white text-[10px] font-black uppercase tracking-widest border border-white/5 transition-all disabled:opacity-50"
|
||
>
|
||
Batch
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
{/* Mockups Grid - COMPACT & REFINED */}
|
||
{mockups.length > 0 ? (
|
||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||
{mockups.map((m, i) => (
|
||
<div key={m.id || i} className="relative group rounded-2xl overflow-hidden border border-white/10 bg-stone-950 aspect-[3/4] shadow-lg transition-transform hover:scale-[1.02]">
|
||
{/* NEURO-ANALYZE BUTTON (New) */}
|
||
<div className="absolute top-6 left-1/2 -translate-x-1/2 z-30">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (activeAssetId) handleNeuroAnalyze(activeAssetId);
|
||
}}
|
||
className="flex items-center gap-2 bg-stone-900/90 hover:bg-black text-white px-4 py-1.5 rounded-full backdrop-blur-md border border-white/20 shadow-2xl transition-all hover:scale-105 group/neuro"
|
||
>
|
||
<BrainCircuit className="w-4 h-4 text-purple-400 group-hover/neuro:animate-pulse" />
|
||
<span className="text-[10px] font-black uppercase tracking-widest">Neuro-Score</span>
|
||
</button>
|
||
</div>
|
||
|
||
<ZoomableImage
|
||
src={`/storage/${m.path}`}
|
||
alt={m.scenario}
|
||
className="w-full h-full object-cover"
|
||
>
|
||
{/* Hover Controls */}
|
||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-between p-3">
|
||
<div className="flex justify-end gap-1.5">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setMockupScenario(m.scenario);
|
||
if (m.aspectRatio) setMockupRatio(m.aspectRatio);
|
||
handleGenerateSingleMockup();
|
||
}}
|
||
disabled={isGeneratingMockups}
|
||
className="p-2 rounded-lg bg-white/10 hover:bg-indigo-500 text-white transition-colors"
|
||
>
|
||
<RefreshCw className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); handleDeleteAsset((m as any).id, 'mockup'); }}
|
||
className="p-2 rounded-lg bg-white/10 hover:bg-red-500 text-white transition-colors"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
<div className="text-[10px] font-bold uppercase tracking-widest text-white/70 truncate">
|
||
{m.scenario.replace(/_/g, ' ')}
|
||
</div>
|
||
</div>
|
||
</ZoomableImage>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col items-center justify-center py-20 border-2 border-dashed border-white/5 rounded-[2rem]">
|
||
<p className="text-white/40 font-bold uppercase tracking-[0.2em] text-[10px]">No Mockups Generated Yet</p>
|
||
<p className="text-white/20 text-[9px] mt-2 uppercase tracking-widest">Select your settings above and click "Create"</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Video Gallery - HORIZONTAL SCROLL */}
|
||
{videos.length > 0 && (
|
||
<div className="mt-12 pt-12 border-t border-white/5 overflow-x-auto custom-scrollbar">
|
||
<div className="flex gap-4 pb-4">
|
||
{videos.map((v, i) => (
|
||
<div key={v.id || i} className="relative group inline-block min-w-[240px] rounded-2xl overflow-hidden border border-white/10 bg-stone-950 aspect-video shadow-lg">
|
||
{v.simulated ? (
|
||
<div className="w-full h-full relative">
|
||
<img src={`/storage/${v.path}`} alt="Video Preview" className="w-full h-full object-cover opacity-50" />
|
||
<div className="absolute inset-0 flex items-center justify-center">
|
||
<span className="px-3 py-1 bg-red-500 text-white text-[8px] font-black uppercase tracking-widest rounded-full">Simulated</span>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<video src={`/storage/${v.path}`} controls className="w-full h-full object-cover" />
|
||
)}
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); handleDeleteAsset((v as any).id, 'video'); setVideos(prev => prev.filter(vid => vid.id !== (v as any).id)); }}
|
||
className="absolute top-2 right-2 p-2 rounded-lg bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-red-500 text-white transition-all"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* VEO VIDEO COMPONENT */}
|
||
{/* <VideoGenerator
|
||
project={projectData.project}
|
||
onVideoGenerated={() => handleSelectProject(projectData.project.id)}
|
||
/> */}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
{/* RIGHT COLUMN: DATA */}
|
||
<div className="xl:col-span-6 space-y-20">
|
||
<div className="flex items-center justify-between px-8">
|
||
<div>
|
||
<h3 className="text-sm font-black uppercase tracking-[0.3em] text-stone-900 flex items-center gap-2">
|
||
<span className="w-1.5 h-6 bg-indigo-500 rounded-full"></span>
|
||
Listing Strategy
|
||
</h3>
|
||
<p className="text-[10px] text-stone-400 font-bold uppercase tracking-widest mt-1">Etsy Metadata & Branding</p>
|
||
</div>
|
||
<button
|
||
onClick={async () => {
|
||
if (!confirm("This will overwrite your current Title, Description, and Tags with a fresh analysis based on your LATEST profile settings and branding. Continue?")) return;
|
||
|
||
try {
|
||
setIsRegeneratingStrategy(true);
|
||
const token = localStorage.getItem('token');
|
||
const pId = projectData?.project?.id || projectData?.id;
|
||
const res = await axios.post(`/api/projects/${pId}/regenerate-strategy`, {}, {
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
|
||
if (res.data.success) {
|
||
// Update UI immediately
|
||
setProjectData((prev: any) => ({
|
||
...prev,
|
||
strategy: {
|
||
...prev.strategy,
|
||
seoTitle: res.data.strategy.seoTitle,
|
||
description: res.data.strategy.description,
|
||
keywords: res.data.strategy.keywords,
|
||
attributes: res.data.strategy.attributes,
|
||
categorySuggestion: res.data.strategy.categorySuggestion,
|
||
jsonLd: res.data.strategy.jsonLd
|
||
}
|
||
}));
|
||
alert("Listing info regenerated successfully!");
|
||
}
|
||
} catch (err: any) {
|
||
console.error("Failed to regenerate strategy", err);
|
||
alert("Error: " + (err.response?.data?.error || err.message));
|
||
} finally {
|
||
setIsRegeneratingStrategy(false);
|
||
}
|
||
}}
|
||
disabled={isRegeneratingStrategy}
|
||
className="px-6 py-3 bg-stone-900 hover:bg-black text-white rounded-2xl text-[10px] font-black uppercase tracking-widest transition-all shadow-sm hover:shadow-md active:scale-95 disabled:bg-stone-300 flex items-center gap-2"
|
||
>
|
||
{isRegeneratingStrategy ? '⏳ Refreshing...' : '🔄 Refresh Listing Info'}
|
||
</button>
|
||
</div>
|
||
|
||
<section className="bg-white p-16 rounded-[5rem] border border-stone-100 shadow-sm space-y-16">
|
||
<div className="flex flex-col gap-4">
|
||
<div className="flex items-center justify-between">
|
||
<label className="text-[10px] font-black uppercase tracking-[0.5em] text-stone-400">SEO Title</label>
|
||
<button
|
||
onClick={() => {
|
||
navigator.clipboard.writeText(projectData.strategy?.seoTitle || "");
|
||
alert("Title copied!");
|
||
}}
|
||
className="text-[10px] font-bold text-indigo-600 hover:text-indigo-700 uppercase tracking-widest bg-indigo-50 px-3 py-1.5 rounded-lg border border-indigo-100 transition-all hover:scale-105 active:scale-95"
|
||
>
|
||
📋 Copy Title
|
||
</button>
|
||
</div>
|
||
<p className="text-4xl font-black text-stone-900 leading-tight tracking-tight">{projectData.strategy?.seoTitle}</p>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-4">
|
||
<div className="flex items-center justify-between">
|
||
<label className="text-[10px] font-black uppercase tracking-[0.5em] text-stone-400">Neuro-Marketing Description</label>
|
||
<button
|
||
onClick={() => {
|
||
navigator.clipboard.writeText(projectData.strategy?.description || "");
|
||
alert("Description copied!");
|
||
}}
|
||
className="text-[10px] font-bold text-indigo-600 hover:text-indigo-700 uppercase tracking-widest bg-indigo-50 px-3 py-1.5 rounded-lg border border-indigo-100 transition-all hover:scale-105 active:scale-95"
|
||
>
|
||
📋 Copy Desc
|
||
</button>
|
||
</div>
|
||
<p className="text-stone-600 font-medium leading-relaxed whitespace-pre-wrap">{projectData.strategy?.description}</p>
|
||
</div>
|
||
|
||
{/* 13 GOLDEN TAGS SECTION */}
|
||
<div className="flex flex-col gap-6 pt-16 border-t border-stone-100">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<label className="text-[10px] font-black uppercase tracking-[0.5em] text-stone-400">13 Golden Tags (Market Ready)</label>
|
||
<p className="text-[9px] text-stone-400 mt-1">Exact 13 tags • Under 20 chars • Long-tail phrases</p>
|
||
</div>
|
||
<button
|
||
onClick={() => {
|
||
const tags = projectData.strategy?.keywords
|
||
? (Array.isArray(projectData.strategy.keywords)
|
||
? projectData.strategy.keywords.map((k: string) => k.replace(/^#/, "").trim()).join(", ") + ","
|
||
: projectData.strategy.keywords.replace(/^#/, ""))
|
||
: "";
|
||
navigator.clipboard.writeText(tags);
|
||
alert("Tags copied!");
|
||
}}
|
||
className="text-[10px] font-bold text-emerald-600 hover:text-emerald-700 uppercase tracking-widest bg-emerald-50 px-3 py-1.5 rounded-lg border border-emerald-100 transition-all hover:scale-105 active:scale-95"
|
||
>
|
||
📋 Copy All Tags
|
||
</button>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{(projectData.strategy?.keywords || []).map((tag: string, idx: number) => (
|
||
<span key={idx} className="bg-stone-50 border border-stone-200 text-stone-600 px-3 py-1.5 rounded-xl text-[11px] font-bold hover:border-stone-400 transition-colors">
|
||
{tag.trim()}
|
||
</span>
|
||
))}
|
||
{(!projectData.strategy?.keywords || projectData.strategy.keywords.length === 0) && (
|
||
<p className="text-stone-400 text-xs italic">No tags generated yet</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ATTRIBUTES & CATEGORY INTELLIGENCE */}
|
||
<div className="pt-16 border-t border-stone-100 grid grid-cols-1 md:grid-cols-2 gap-8">
|
||
|
||
{/* Category */}
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<label className="text-[10px] font-black uppercase tracking-[0.5em] text-stone-400">Category Path</label>
|
||
<button onClick={() => { navigator.clipboard.writeText(projectData.strategy?.categorySuggestion || ""); alert("Category copied!"); }} className="text-stone-400 hover:text-stone-600">📋</button>
|
||
</div>
|
||
<div className="bg-stone-50 border border-stone-200 rounded-xl p-4">
|
||
<p className="text-stone-700 text-xs font-bold font-mono">{projectData.strategy?.categorySuggestion || "Not yet generated"}</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Hidden Attributes */}
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<label className="text-[10px] font-black uppercase tracking-[0.5em] text-stone-400">Hidden Attributes</label>
|
||
<button onClick={() => {
|
||
const attrs = projectData.strategy?.attributes;
|
||
const text = attrs ? `Primary Color: ${attrs.primaryColor}\nSecondary Color: ${attrs.secondaryColor}\nHoliday: ${attrs.date}\nOccasion: ${attrs.occasion}\nRoom: ${attrs.room}` : "";
|
||
navigator.clipboard.writeText(text);
|
||
alert("Attributes copied!");
|
||
}} className="text-stone-400 hover:text-stone-600">📋</button>
|
||
</div>
|
||
<div className="bg-stone-50 border border-stone-200 rounded-xl p-4 space-y-2">
|
||
{projectData.strategy?.attributes ? (
|
||
<>
|
||
<div className="flex justify-between text-[10px]"><span className="text-stone-400 font-bold uppercase">Color 1</span> <span className="font-bold text-stone-700">{projectData.strategy.attributes.primaryColor || "-"}</span></div>
|
||
<div className="flex justify-between text-[10px]"><span className="text-stone-400 font-bold uppercase">Color 2</span> <span className="font-bold text-stone-700">{projectData.strategy.attributes.secondaryColor || "-"}</span></div>
|
||
<div className="flex justify-between text-[10px]"><span className="text-stone-400 font-bold uppercase">Holiday</span> <span className="font-bold text-stone-700">{projectData.strategy.attributes.date || "-"}</span></div>
|
||
<div className="flex justify-between text-[10px]"><span className="text-stone-400 font-bold uppercase">Occasion</span> <span className="font-bold text-stone-700">{projectData.strategy.attributes.occasion || "-"}</span></div>
|
||
<div className="flex justify-between text-[10px]"><span className="text-stone-400 font-bold uppercase">Room</span> <span className="font-bold text-stone-700">{projectData.strategy.attributes.room || "-"}</span></div>
|
||
</>
|
||
) : (
|
||
<p className="text-stone-400 text-xs italic">No attributes generated yet</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="pt-16 border-t border-stone-50">
|
||
</div>
|
||
|
||
{/* DOWNLOAD STATION */}
|
||
<div className="pt-16 border-t border-stone-50">
|
||
<label className="text-[10px] font-black uppercase tracking-[0.5em] text-stone-400 block mb-8">Production Assets</label>
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
{/* 1. MASTER ZIP */}
|
||
<button
|
||
onClick={async (e) => {
|
||
e.preventDefault();
|
||
try {
|
||
const token = localStorage.getItem('token');
|
||
const pId = projectData.project?.id || projectData.id;
|
||
const res = await fetch(`/api/projects/${pId}/download/master-zip`, {
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
|
||
const blob = await res.blob();
|
||
const url = window.URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `MasterBundle_${projectData.project?.productType || 'Project'}_${pId.substring(0, 6)}.zip`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
window.URL.revokeObjectURL(url);
|
||
a.remove();
|
||
} catch (err: any) {
|
||
alert("Download Error: " + err.message);
|
||
}
|
||
}}
|
||
className="flex flex-col items-center justify-center p-6 bg-stone-900 rounded-3xl hover:bg-stone-800 transition-all group shadow-xl cursor-pointer"
|
||
title="Download Everything (Source Files)"
|
||
>
|
||
<span className="text-3xl group-hover:scale-110 transition-transform mb-3">📦</span>
|
||
<span className="text-[10px] font-black uppercase tracking-widest text-white">Master Bundle</span>
|
||
<span className="text-[9px] text-stone-500 mt-1">Source + Mockups</span>
|
||
</button>
|
||
|
||
{/* 2. CUSTOMER RGB */}
|
||
<button
|
||
onClick={async (e) => {
|
||
e.preventDefault();
|
||
try {
|
||
const token = localStorage.getItem('token');
|
||
const pId = projectData.project?.id || projectData.id;
|
||
const res = await fetch(`/api/projects/${pId}/download/customer-rgb-zip`, {
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
|
||
const blob = await res.blob();
|
||
const url = window.URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `CustomerDownload_RGB_${pId.substring(0, 6)}.zip`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
window.URL.revokeObjectURL(url);
|
||
a.remove();
|
||
} catch (err: any) {
|
||
alert("Download Error: " + err.message);
|
||
}
|
||
}}
|
||
className="flex flex-col items-center justify-center p-6 bg-white border border-stone-200 rounded-3xl hover:border-blue-300 hover:bg-blue-50 transition-all group cursor-pointer"
|
||
title="Customer Delivery (Digital)"
|
||
>
|
||
<span className="text-3xl group-hover:scale-110 transition-transform mb-3">🎨</span>
|
||
<span className="text-[10px] font-black uppercase tracking-widest text-stone-900">Digital (RGB)</span>
|
||
<span className="text-[9px] text-stone-400 mt-1">For Web & Screens</span>
|
||
</button>
|
||
|
||
{/* 3. CUSTOMER CMYK */}
|
||
<button
|
||
onClick={async (e) => {
|
||
e.preventDefault();
|
||
try {
|
||
const token = localStorage.getItem('token');
|
||
const pId = projectData.project?.id || projectData.id;
|
||
const res = await fetch(`/api/projects/${pId}/download/customer-cmyk-zip`, {
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
|
||
const blob = await res.blob();
|
||
const url = window.URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `CustomerDownload_CMYK_${pId.substring(0, 6)}.zip`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
window.URL.revokeObjectURL(url);
|
||
a.remove();
|
||
} catch (err: any) {
|
||
alert("Download Error: " + err.message);
|
||
}
|
||
}}
|
||
className="flex flex-col items-center justify-center p-6 bg-white border border-stone-200 rounded-3xl hover:border-cyan-300 hover:bg-cyan-50 transition-all group cursor-pointer"
|
||
title="Customer Delivery (Print Ready)"
|
||
>
|
||
<span className="text-3xl group-hover:scale-110 transition-transform mb-3">🖨️</span>
|
||
<span className="text-[10px] font-black uppercase tracking-widest text-stone-900">Print (CMYK)</span>
|
||
<span className="text-[9px] text-stone-400 mt-1">High-Res Production</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div >
|
||
</div >
|
||
)
|
||
}
|
||
</main >
|
||
|
||
{/* NEURO-SCORECARD MODAL */}
|
||
{
|
||
showNeuroModal && (
|
||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||
<div className="absolute inset-0 bg-stone-900/80 backdrop-blur-sm" onClick={() => setShowNeuroModal(false)} />
|
||
<div className="relative w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
||
<button
|
||
onClick={() => setShowNeuroModal(false)}
|
||
className="absolute -top-12 right-0 text-white hover:text-stone-300 transition-colors"
|
||
>
|
||
<X className="w-8 h-8" />
|
||
</button>
|
||
<NeuroScorecard
|
||
analysis={neuroAnalysis}
|
||
loading={isAnalyzing}
|
||
onApplyImprovement={handleApplyNeuroFix}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
</Layout >
|
||
);
|
||
};
|
||
|
||
export default Home;
|