diff --git a/.gitignore b/.gitignore index 54f67ce..3661351 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ dogfood-output/ canvas/fluid.db canvas/fluid.db-wal canvas/fluid.db-shm +# Environment (tokens, secrets — never commit) +canvas/.env +.env +.env.local diff --git a/canvas/.env.example b/canvas/.env.example new file mode 100644 index 0000000..7754480 --- /dev/null +++ b/canvas/.env.example @@ -0,0 +1,3 @@ +# Fluid API token for the DAM Picker (see https://docs.fluid.app/docs/guides/dam-picker-sdk-guide) +# Copy this file to .env and replace the value with your token. +VITE_FLUID_DAM_TOKEN= diff --git a/canvas/src/App.tsx b/canvas/src/App.tsx index ff8f16c..5e92f73 100644 --- a/canvas/src/App.tsx +++ b/canvas/src/App.tsx @@ -224,12 +224,12 @@ export function App() { ); } - // Campaigns tab: only show campaigns list (folders). Creations live in the Creations tab. - if (createViewportTab === 'campaigns') { + // Campaigns tab: show campaign list only at dashboard; when a campaign is selected, show drill-down in same tab. + if (createViewportTab === 'campaigns' && currentView === 'dashboard') { return ; } - // Creations tab: show creations hierarchy (creations → slides → iterations) + // Creations tab at dashboard, or either tab when drilled in: show creations hierarchy (creations → slides → iterations) switch (currentView) { case 'dashboard': return ( diff --git a/canvas/src/components/AppShell.tsx b/canvas/src/components/AppShell.tsx index cbb1ef1..87233c7 100644 --- a/canvas/src/components/AppShell.tsx +++ b/canvas/src/components/AppShell.tsx @@ -4,6 +4,7 @@ import { Breadcrumb } from './Breadcrumb'; import { LeftNav } from './LeftNav'; import { ChatSidebar } from './ChatSidebar'; import { VoiceGuide } from './VoiceGuide'; +import { BuildHero } from './BuildHero'; interface AppShellProps { /** @@ -71,7 +72,13 @@ export function AppShell({ leftSidebar, rightSidebar, children, onNewCreation }: case 'create': return (
- {/* Create viewport header: tabs row + breadcrumb row */} + +
+ ); + + case 'my-creations': + return ( +
- New Campaign + Create New ) : onNewCreation ? ( ) : null}
@@ -190,7 +197,6 @@ export function AppShell({ leftSidebar, rightSidebar, children, onNewCreation }:
- {/* Campaign drill-down content */}
{children}
diff --git a/canvas/src/components/BuildHero.tsx b/canvas/src/components/BuildHero.tsx new file mode 100644 index 0000000..f8464fd --- /dev/null +++ b/canvas/src/components/BuildHero.tsx @@ -0,0 +1,1209 @@ +import { useState, useRef, useEffect } from 'react'; +import { nanoid } from 'nanoid'; +import { FluidDAMModal } from './DAMPicker'; + +// Project design tokens (from index.css) +const BG_PRIMARY = 'var(--bg-primary, #0d0d0d)'; +const BG_CARD = 'var(--bg-card, #1a1a1e)'; +const BG_SECONDARY = 'var(--bg-secondary, #141414)'; +const BORDER = 'var(--border, #1e1e1e)'; +const BORDER_HOVER = 'var(--border-hover, #2a2a2e)'; +const ACCENT = 'var(--accent, #44B2FF)'; +const TEXT_PRIMARY = 'var(--text-primary, #e0e0e0)'; +const TEXT_SECONDARY = 'var(--text-secondary, #888)'; +const TEXT_MUTED = 'var(--text-muted, #555)'; + +const CREATION_TYPES = [ + { id: 'campaign', label: 'Campaign' }, + { id: 'social-post', label: 'Social Post' }, + { id: 'instagram-story', label: 'Instagram Story or video' }, + { id: 'blog-post', label: 'Blog Post' }, + { id: 'one-pager', label: 'One Pager' }, + { id: 'press-release', label: 'Press Release' }, + { id: 'ad', label: 'AD' }, +] as const; + +const SOCIAL_POST_FORMATS = [ + { id: 'single', label: 'Single post' }, + { id: 'carousel', label: 'Carousel' }, +] as const; + +const SOCIAL_POST_DIMENSIONS = [ + { id: 'instagram-4-5', dimensions: '1080×1350', sublabel: 'Instagram 4:5' }, + { id: 'linkedin-1080-1350', dimensions: '1080×1350', sublabel: 'LinkedIn' }, + { id: 'linkedin-1200-627', dimensions: '1200×627', sublabel: 'LinkedIn' }, +] as const; + +const VIDEO_FORMATS = [ + { id: 'story', label: 'Story' }, + { id: 'video', label: 'Video' }, +] as const; + +const VIDEO_DIMENSIONS = [ + { id: '1080-1920', dimensions: '1080×1920', sublabel: 'Story / Reel / TikTok', formats: ['story', 'video'] as const }, + { id: '1920-1080', dimensions: '1920×1080', sublabel: 'Landscape', formats: ['video'] as const }, + { id: '1080-1080', dimensions: '1080×1080', sublabel: 'Square', formats: ['video'] as const }, +] as const; + +const SUGGESTIONS = [ + { id: 'db-auth', text: 'Add database and auth', icon: 'flame' }, + { id: 'nano-banana', text: 'Nano Banana 2', icon: 'brain' }, + { id: 'voice-apps', text: 'Create conversational voice apps', icon: 'sparkles' }, + { id: 'animate', text: 'Animate images with...', icon: 'image' }, +]; + +function SparklesIcon({ size = 20, accent = false }: { size?: number; accent?: boolean }) { + return ( + + + + ); +} + +function SettingsIcon({ size = 16 }: { size?: number }) { + return ( + + + + + ); +} + +function MicIcon({ size = 20 }: { size?: number }) { + return ( + + + + + + + ); +} + +function PlusIcon({ size = 20 }: { size?: number }) { + return ( + + + + + ); +} + +function ArrowRightIcon({ size = 16 }: { size?: number }) { + return ( + + + + + ); +} + +function ChevronRightIcon({ size = 16 }: { size?: number }) { + return ( + + + + ); +} + +function ChevronLeftIcon({ size = 16 }: { size?: number }) { + return ( + + + + ); +} + +function ChevronDownIcon({ size = 16 }: { size?: number }) { + return ( + + + + ); +} + +function SuggestionIcon({ type }: { type: string }) { + const size = 16; + const flame = '#fb923c'; + const brain = ACCENT; + const sparkles = '#a78bfa'; + const image = '#93c5fd'; + const color = type === 'flame' ? flame : type === 'brain' ? brain : type === 'sparkles' ? sparkles : image; + if (type === 'flame') { + return ( + + + + ); + } + if (type === 'brain') { + return ( + + + + + ); + } + if (type === 'sparkles') { + return ( + + + + ); + } + return ( + + + + + + ); +} + +const SCROLL_EDGE = 4; +const FADE_WIDTH = 96; + +function usePillsOverflow() { + const scrollRef = useRef(null); + const [left, setLeft] = useState(false); + const [right, setRight] = useState(false); + + const update = () => { + const el = scrollRef.current; + if (!el) return; + const { scrollLeft, scrollWidth, clientWidth } = el; + setLeft(scrollLeft > SCROLL_EDGE); + setRight(scrollLeft + clientWidth < scrollWidth - SCROLL_EDGE); + }; + + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + update(); + el.addEventListener('scroll', update); + const ro = new ResizeObserver(update); + ro.observe(el); + return () => { + el.removeEventListener('scroll', update); + ro.disconnect(); + }; + }, []); + + const scrollLeft = () => { + scrollRef.current?.scrollBy({ left: -200, behavior: 'smooth' }); + }; + const scrollRight = () => { + scrollRef.current?.scrollBy({ left: 200, behavior: 'smooth' }); + }; + + return { scrollRef, showLeft: left, showRight: right, scrollLeft, scrollRight }; +} + +export function BuildHero() { + const [inputValue, setInputValue] = useState(''); + const [creationTypeId, setCreationTypeId] = useState(''); + const [creationDropdownOpen, setCreationDropdownOpen] = useState(false); + const [socialPostFormatId, setSocialPostFormatId] = useState(SOCIAL_POST_FORMATS[0].id); + const [socialPostFormatDropdownOpen, setSocialPostFormatDropdownOpen] = useState(false); + const [socialPostDimensionId, setSocialPostDimensionId] = useState(SOCIAL_POST_DIMENSIONS[0].id); + const [socialPostDimensionDropdownOpen, setSocialPostDimensionDropdownOpen] = useState(false); + const [videoFormatId, setVideoFormatId] = useState(VIDEO_FORMATS[0].id); + const [videoFormatDropdownOpen, setVideoFormatDropdownOpen] = useState(false); + const [videoDimensionId, setVideoDimensionId] = useState(VIDEO_DIMENSIONS[0].id); + const [videoDimensionDropdownOpen, setVideoDimensionDropdownOpen] = useState(false); + const [damModalOpen, setDamModalOpen] = useState(false); + const [selectedDamAssets, setSelectedDamAssets] = useState>([]); + const creationDropdownRef = useRef(null); + const socialPostFormatDropdownRef = useRef(null); + const socialPostDimensionDropdownRef = useRef(null); + const videoFormatDropdownRef = useRef(null); + const videoDimensionDropdownRef = useRef(null); + const { scrollRef, showLeft, showRight, scrollLeft, scrollRight } = usePillsOverflow(); + + useEffect(() => { + if (!creationDropdownOpen) return; + const handleClickOutside = (e: MouseEvent) => { + if (creationDropdownRef.current && !creationDropdownRef.current.contains(e.target as Node)) { + setCreationDropdownOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [creationDropdownOpen]); + + useEffect(() => { + if (!socialPostFormatDropdownOpen) return; + const handleClickOutside = (e: MouseEvent) => { + if (socialPostFormatDropdownRef.current && !socialPostFormatDropdownRef.current.contains(e.target as Node)) { + setSocialPostFormatDropdownOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [socialPostFormatDropdownOpen]); + + useEffect(() => { + if (!socialPostDimensionDropdownOpen) return; + const handleClickOutside = (e: MouseEvent) => { + if (socialPostDimensionDropdownRef.current && !socialPostDimensionDropdownRef.current.contains(e.target as Node)) { + setSocialPostDimensionDropdownOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [socialPostDimensionDropdownOpen]); + + useEffect(() => { + if (!videoFormatDropdownOpen) return; + const handleClickOutside = (e: MouseEvent) => { + if (videoFormatDropdownRef.current && !videoFormatDropdownRef.current.contains(e.target as Node)) { + setVideoFormatDropdownOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [videoFormatDropdownOpen]); + + useEffect(() => { + if (!videoDimensionDropdownOpen) return; + const handleClickOutside = (e: MouseEvent) => { + if (videoDimensionDropdownRef.current && !videoDimensionDropdownRef.current.contains(e.target as Node)) { + setVideoDimensionDropdownOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [videoDimensionDropdownOpen]); + + const videoDimensionsForFormat = VIDEO_DIMENSIONS.filter((d) => + d.formats.includes(videoFormatId as 'story' | 'video') + ); + useEffect(() => { + if (videoFormatId === 'story' && videoDimensionId !== '1080-1920') { + setVideoDimensionId('1080-1920'); + } + }, [videoFormatId, videoDimensionId]); + + const selectedCreation = creationTypeId ? CREATION_TYPES.find((t) => t.id === creationTypeId) ?? null : null; + const selectedSocialPostFormat = SOCIAL_POST_FORMATS.find((f) => f.id === socialPostFormatId) ?? SOCIAL_POST_FORMATS[0]; + const selectedSocialPostDimension = SOCIAL_POST_DIMENSIONS.find((d) => d.id === socialPostDimensionId) ?? SOCIAL_POST_DIMENSIONS[0]; + const selectedVideoFormat = VIDEO_FORMATS.find((f) => f.id === videoFormatId) ?? VIDEO_FORMATS[0]; + const selectedVideoDimension = + videoDimensionsForFormat.find((d) => d.id === videoDimensionId) ?? videoDimensionsForFormat[0]; + const isSocialPost = creationTypeId === 'social-post'; + const isVideo = creationTypeId === 'instagram-story'; + + return ( +
+ {/* Header */} +
+

+ Build your ideas with Gemini +

+
+ +
+
+ + {/* Main Input Container */} +
+ {/* Gradient border glow (kept per request) */} +
+
+