diff --git a/canvas/src/components/AppShell.tsx b/canvas/src/components/AppShell.tsx index 87233c7..b49e801 100644 --- a/canvas/src/components/AppShell.tsx +++ b/canvas/src/components/AppShell.tsx @@ -5,6 +5,7 @@ import { LeftNav } from './LeftNav'; import { ChatSidebar } from './ChatSidebar'; import { VoiceGuide } from './VoiceGuide'; import { BuildHero } from './BuildHero'; +import { AssetsScreen } from './AssetsScreen'; interface AppShellProps { /** @@ -71,11 +72,18 @@ export function AppShell({ leftSidebar, rightSidebar, children, onNewCreation }: switch (activeNavTab) { case 'create': return ( -
+
); + case 'assets': + return ( +
+ +
+ ); + case 'my-creations': return (
diff --git a/canvas/src/components/AssetsScreen.tsx b/canvas/src/components/AssetsScreen.tsx new file mode 100644 index 0000000..036e2a8 --- /dev/null +++ b/canvas/src/components/AssetsScreen.tsx @@ -0,0 +1,272 @@ +/** + * AssetsScreen — user library of saved assets. + * Add assets from Fluid DAM; list and remove saved assets (persisted via /api/assets). + */ + +import { useState, useEffect, useCallback } from 'react'; +import { FluidDAMModal, type SelectedDAMAsset } from './DAMPicker'; + +interface SavedAsset { + id: string; + url: string; + name: string | null; + mimeType: string | null; + source: 'dam' | 'upload'; + createdAt: number; +} + +export function AssetsScreen() { + const [assets, setAssets] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [damOpen, setDamOpen] = useState(false); + const [deletingId, setDeletingId] = useState(null); + + const fetchAssets = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch('/api/assets'); + if (!res.ok) throw new Error('Failed to load assets'); + const data = await res.json(); + setAssets(Array.isArray(data) ? data : []); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load assets'); + setAssets([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchAssets(); + }, [fetchAssets]); + + const handleAddFromDAM = () => setDamOpen(true); + + const handleDAMSelect = async (asset: SelectedDAMAsset) => { + setDamOpen(false); + try { + const res = await fetch('/api/assets', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: asset.url, name: asset.name ?? null, source: 'dam' }), + }); + if (!res.ok) throw new Error('Failed to save asset'); + await fetchAssets(); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to save asset'); + } + }; + + const handleRemove = async (id: string) => { + setDeletingId(id); + try { + const res = await fetch(`/api/assets/${id}`, { method: 'DELETE' }); + if (!res.ok) throw new Error('Failed to remove'); + await fetchAssets(); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to remove'); + } finally { + setDeletingId(null); + } + }; + + const isImage = (mime: string | null, url: string) => { + if (mime?.startsWith('image/')) return true; + return /\.(jpe?g|png|gif|webp|avif)(\?|$)/i.test(url); + }; + + return ( +
+
+

+ Assets +

+ +
+ + {error && ( +
+ {error} +
+ )} + + {loading ? ( +
Loading assets…
+ ) : assets.length === 0 ? ( +
+

No saved assets yet.

+

Use “Add from Fluid DAM” to browse and save assets to your library.

+
+ ) : ( +
+ {assets.map((a) => ( +
+
+ {isImage(a.mimeType, a.url) ? ( + {a.name + ) : ( + File + )} +
+
+ + {a.name ?? 'Asset'} + + +
+
+ ))} +
+ )} + + setDamOpen(false)} + onError={(msg) => setError(msg)} + /> +
+ ); +} + +function PlusIcon() { + return ( + + + + + ); +} + +function TrashIcon() { + return ( + + + + + + + ); +} diff --git a/canvas/src/components/BuildHero.tsx b/canvas/src/components/BuildHero.tsx index f8464fd..677b303 100644 --- a/canvas/src/components/BuildHero.tsx +++ b/canvas/src/components/BuildHero.tsx @@ -1,6 +1,14 @@ -import { useState, useRef, useEffect } from 'react'; +import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { nanoid } from 'nanoid'; import { FluidDAMModal } from './DAMPicker'; +import { IdeasGetStarted, type IdeaAction } from './IdeasGetStarted'; + +/** Saved asset from /api/assets (same shape as IdeasGetStarted.SelectedAsset). */ +interface SavedAssetForIdeas { + id: string; + url: string; + name?: string | null; +} // Project design tokens (from index.css) const BG_PRIMARY = 'var(--bg-primary, #0d0d0d)'; @@ -213,6 +221,8 @@ export function BuildHero() { const [videoDimensionDropdownOpen, setVideoDimensionDropdownOpen] = useState(false); const [damModalOpen, setDamModalOpen] = useState(false); const [selectedDamAssets, setSelectedDamAssets] = useState>([]); + const [savedAssets, setSavedAssets] = useState([]); + const [selectedTemplateId, setSelectedTemplateId] = useState(null); const creationDropdownRef = useRef(null); const socialPostFormatDropdownRef = useRef(null); const socialPostDimensionDropdownRef = useRef(null); @@ -275,6 +285,22 @@ export function BuildHero() { return () => document.removeEventListener('mousedown', handleClickOutside); }, [videoDimensionDropdownOpen]); + // Load saved assets (from Assets tab) so Ideas Get Started can suggest remixes + useEffect(() => { + let cancelled = false; + fetch('/api/assets') + .then((res) => (res.ok ? res.json() : [])) + .then((data: Array<{ id: string; url: string; name?: string | null }>) => { + if (!cancelled && Array.isArray(data)) { + setSavedAssets(data.map((a) => ({ id: a.id, url: a.url, name: a.name ?? undefined }))); + } + }) + .catch(() => { + if (!cancelled) setSavedAssets([]); + }); + return () => { cancelled = true; }; + }, []); + const videoDimensionsForFormat = VIDEO_DIMENSIONS.filter((d) => d.formats.includes(videoFormatId as 'story' | 'video') ); @@ -293,20 +319,51 @@ export function BuildHero() { const isSocialPost = creationTypeId === 'social-post'; const isVideo = creationTypeId === 'instagram-story'; + const ideasAssets = useMemo( + () => [...selectedDamAssets, ...savedAssets], + [selectedDamAssets, savedAssets] + ); + + const handleApplyIdea = useCallback((idea: IdeaAction) => { + if (idea.creationType) setCreationTypeId(idea.creationType); + if (idea.promptSuggestion) setInputValue(idea.promptSuggestion); + if (idea.socialPostFormatId) setSocialPostFormatId(idea.socialPostFormatId); + if (idea.socialPostDimensionId) setSocialPostDimensionId(idea.socialPostDimensionId); + if (idea.videoFormatId) setVideoFormatId(idea.videoFormatId); + if (idea.videoDimensionId) setVideoDimensionId(idea.videoDimensionId); + if (idea.templateId != null) setSelectedTemplateId(idea.templateId); + }, []); + return (
+ {/* Single centered column: title + input + chips + Discover (screenshot layout) */} +
{/* Header */}

- Build your ideas with Gemini + What do you want to create today?

@@ -1204,6 +1261,12 @@ export function BuildHero() {
)}
+ + {/* Discover and remix ideas — part of the same centered block as in screenshot */} +
+ +
+
); } diff --git a/canvas/src/components/IdeasGetStarted.tsx b/canvas/src/components/IdeasGetStarted.tsx new file mode 100644 index 0000000..4fec2c6 --- /dev/null +++ b/canvas/src/components/IdeasGetStarted.tsx @@ -0,0 +1,573 @@ +import { useRef, useState, useEffect, useMemo } from 'react'; + +const BG_PRIMARY = 'var(--bg-primary, #0d0d0d)'; +const BG_CARD = 'var(--bg-card, #1a1a1e)'; +const BORDER = 'var(--border, #1e1e1e)'; +const BORDER_HOVER = 'var(--border-hover, #2a2a2e)'; +const TEXT_PRIMARY = 'var(--text-primary, #e0e0e0)'; +const TEXT_SECONDARY = 'var(--text-secondary, #888)'; + +export interface SelectedAsset { + id: string; + url: string; + name?: string; +} + +export interface IdeaAction { + creationType?: string; + promptSuggestion?: string; + socialPostFormatId?: string; + socialPostDimensionId?: string; + videoFormatId?: string; + videoDimensionId?: string; + templateId?: string; +} + +export interface IdeasGetStartedProps { + selectedAssets: SelectedAsset[]; + onApplyIdea?: (idea: IdeaAction) => void; +} + +/** Template from GET /api/templates (minimal shape we need). */ +interface TemplateMeta { + id: string; + name: string; + category: 'social' | 'one-pager'; +} + +/** Idea template: creation type, format/dims, and varied title/description/prompt phrasing. No dimensions in UI. */ +const IDEA_TEMPLATES: Array<{ + id: string; + title: string; + description: string; + badgeLabel: string; + promptPhrases: string[]; + creationType: string; + socialPostFormatId?: string; + socialPostDimensionId?: string; + videoFormatId?: string; + videoDimensionId?: string; +}> = [ + { + id: 'story', + title: 'Instagram Story', + description: 'Story or reel.', + badgeLabel: 'Story', + promptPhrases: ['Create an Instagram story from this', 'Turn this into a story', 'Remix as an Instagram story'], + creationType: 'instagram-story', + videoFormatId: 'story', + videoDimensionId: '1080-1920', + }, + { + id: 'linkedin-post', + title: 'LinkedIn post (portrait)', + description: 'Single post.', + badgeLabel: 'Social Post', + promptPhrases: ['Create a LinkedIn post from this', 'Turn this into a LinkedIn post', 'Repurpose for LinkedIn'], + creationType: 'social-post', + socialPostFormatId: 'single', + socialPostDimensionId: 'linkedin-1080-1350', + }, + { + id: 'linkedin-banner', + title: 'LinkedIn banner', + description: 'Landscape banner.', + badgeLabel: 'Social Post', + promptPhrases: ['Create a LinkedIn banner from this', 'Turn this into a LinkedIn banner image'], + creationType: 'social-post', + socialPostFormatId: 'single', + socialPostDimensionId: 'linkedin-1200-627', + }, + { + id: 'instagram-4-5', + title: 'Instagram 4:5 single', + description: 'Feed post.', + badgeLabel: 'Social Post', + promptPhrases: ['Create an Instagram post from this', 'Turn this into an Instagram 4:5 post'], + creationType: 'social-post', + socialPostFormatId: 'single', + socialPostDimensionId: 'instagram-4-5', + }, + { + id: 'carousel-4-5', + title: 'Instagram carousel', + description: 'Multi-slide carousel.', + badgeLabel: 'Carousel', + promptPhrases: ['Create an Instagram carousel from this', 'Turn this into a carousel', 'Remix as a multi-slide carousel'], + creationType: 'social-post', + socialPostFormatId: 'carousel', + socialPostDimensionId: 'instagram-4-5', + }, + { + id: 'carousel-linkedin', + title: 'LinkedIn carousel', + description: 'Multi-slide carousel.', + badgeLabel: 'Carousel', + promptPhrases: ['Create a LinkedIn carousel from this', 'Turn this into a LinkedIn carousel'], + creationType: 'social-post', + socialPostFormatId: 'carousel', + socialPostDimensionId: 'linkedin-1080-1350', + }, + { + id: 'video-landscape', + title: 'Video (landscape)', + description: 'Landscape video.', + badgeLabel: 'Video', + promptPhrases: ['Repurpose this as a landscape video', 'Turn this into a landscape video', 'Create a video from this'], + creationType: 'instagram-story', + videoFormatId: 'video', + videoDimensionId: '1920-1080', + }, + { + id: 'video-square', + title: 'Video (square)', + description: 'Square video.', + badgeLabel: 'Video', + promptPhrases: ['Repurpose this as a square video', 'Turn this into a square video'], + creationType: 'instagram-story', + videoFormatId: 'video', + videoDimensionId: '1080-1080', + }, + { + id: 'story-reel', + title: 'Story / Reel', + description: 'Vertical for Reels or TikTok.', + badgeLabel: 'Story', + promptPhrases: ['Create a Reel from this', 'Turn this into a Reel', 'Remix as a vertical short'], + creationType: 'instagram-story', + videoFormatId: 'story', + videoDimensionId: '1080-1920', + }, +]; + +type IdeaItem = { + id: string; + title: string; + description: string; + badgeLabel: string; + creationType: string; + promptSuggestion: string; + socialPostFormatId?: string; + socialPostDimensionId?: string; + videoFormatId?: string; + videoDimensionId?: string; + thumbnailUrl?: string; + templateId?: string; +}; + +/** Client-side rule-based ideas with variation: rotate asset per idea, vary prompt phrasing. */ +function deriveAssetIdeas(assets: SelectedAsset[]): IdeaItem[] { + if (assets.length === 0) return []; + return IDEA_TEMPLATES.map((tpl, i) => { + const asset = assets[i % assets.length]; + const label = asset.name || 'your asset'; + const phraseIndex = i % tpl.promptPhrases.length; + const promptPhrase = tpl.promptPhrases[phraseIndex]; + const promptSuggestion = `${promptPhrase}: ${label}`; + return { + id: `idea-${tpl.id}-${i}`, + title: tpl.title, + description: tpl.description, + badgeLabel: tpl.badgeLabel, + creationType: tpl.creationType, + promptSuggestion, + socialPostFormatId: tpl.socialPostFormatId, + socialPostDimensionId: tpl.socialPostDimensionId, + videoFormatId: tpl.videoFormatId, + videoDimensionId: tpl.videoDimensionId, + thumbnailUrl: asset.url, + }; + }); +} + +const TEMPLATE_PROMPT_PHRASES = [ + 'Use the {{name}} template', + 'Start from {{name}} template', + 'Create with {{name}} template', +]; + +/** Template-based ideas: one card per template from the library. */ +function deriveTemplateIdeas(templates: TemplateMeta[]): IdeaItem[] { + return templates.map((t, i) => { + const phrase = TEMPLATE_PROMPT_PHRASES[i % TEMPLATE_PROMPT_PHRASES.length].replace('{{name}}', t.name); + const creationType = t.category === 'social' ? 'social-post' : 'one-pager'; + const badgeLabel = t.category === 'social' ? 'Template' : 'One-pager'; + return { + id: `template-${t.id}`, + title: t.name, + description: t.category === 'social' ? 'Social post template' : 'One-pager template', + badgeLabel, + creationType, + promptSuggestion: phrase, + templateId: t.id, + }; + }); +} + +const SCROLL_EDGE = 4; +const FADE_WIDTH = 96; + +function useCardsScroll() { + const scrollRef = useRef(null); + const [showLeft, setShowLeft] = useState(false); + const [showRight, setShowRight] = useState(false); + + const update = () => { + const el = scrollRef.current; + if (!el) return; + const { scrollLeft, scrollWidth, clientWidth } = el; + setShowLeft(scrollLeft > SCROLL_EDGE); + setShowRight(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: -280, behavior: 'smooth' }); + const scrollRight = () => scrollRef.current?.scrollBy({ left: 280, behavior: 'smooth' }); + + return { scrollRef, showLeft, showRight, scrollLeft, scrollRight }; +} + +function ChevronLeftIcon({ size = 16 }: { size?: number }) { + return ( + + + + ); +} + +function ChevronRightIcon({ size = 16 }: { size?: number }) { + return ( + + + + ); +} + +export function IdeasGetStarted({ selectedAssets, onApplyIdea }: IdeasGetStartedProps) { + const [templates, setTemplates] = useState([]); + + useEffect(() => { + let cancelled = false; + fetch('/api/templates') + .then((res) => (res.ok ? res.json() : [])) + .then((data: TemplateMeta[]) => { + if (!cancelled && Array.isArray(data)) { + setTemplates(data.map((t) => ({ id: t.id, name: t.name, category: t.category }))); + } + }) + .catch(() => { + if (!cancelled) setTemplates([]); + }); + return () => { cancelled = true; }; + }, []); + + const ideas = useMemo( + () => [...deriveAssetIdeas(selectedAssets), ...deriveTemplateIdeas(templates)], + [selectedAssets, templates] + ); + const { scrollRef, showLeft, showRight, scrollLeft, scrollRight } = useCardsScroll(); + + const hasAnyIdeas = ideas.length > 0; + if (!hasAnyIdeas) { + return ( +
+

+ Ideas Get Started +

+

+ Add assets above with the + button or save assets in the Assets tab. Templates will appear here when available. +

+
+ ); + } + + return ( +
+
+

+ Discover and remix ideas +

+
+
+
+ {ideas.map((idea) => ( + + ))} +
+
+ {showLeft && ( +
+ +
+ )} + {showRight && ( +
+ +
+ )} +
+ ); +} diff --git a/canvas/src/components/LeftNav.tsx b/canvas/src/components/LeftNav.tsx index 4c9c4db..57e2c65 100644 --- a/canvas/src/components/LeftNav.tsx +++ b/canvas/src/components/LeftNav.tsx @@ -51,6 +51,17 @@ function PatternsIcon() { ); } +function AssetsIcon() { + return ( + + + + + + ); +} + function VoiceGuideIcon() { return ( ): SavedAsset { + return { + id: row.id as string, + url: row.url as string, + name: (row.name as string | null) ?? null, + mimeType: (row.mime_type as string | null) ?? null, + source: (row.source as 'dam' | 'upload') || 'dam', + createdAt: row.created_at as number, + }; +} + +export function getSavedAssets(): SavedAsset[] { + const db = getDb(); + const rows = db.prepare( + 'SELECT * FROM saved_assets ORDER BY created_at DESC' + ).all() as Record[]; + return rows.map(rowToSavedAsset); +} + +export function createSavedAsset(input: { + url: string; + name?: string | null; + mimeType?: string | null; + source?: 'dam' | 'upload'; +}): SavedAsset { + const db = getDb(); + const id = nanoid(); + const now = Date.now(); + const source = input.source ?? 'dam'; + db.prepare( + 'INSERT INTO saved_assets (id, url, name, mime_type, source, created_at) VALUES (?, ?, ?, ?, ?, ?)' + ).run( + id, + input.url, + input.name ?? null, + input.mimeType ?? null, + source, + now + ); + return { + id, + url: input.url, + name: input.name ?? null, + mimeType: input.mimeType ?? null, + source, + createdAt: now, + }; +} + +export function deleteSavedAsset(id: string): void { + const db = getDb(); + const stmt = db.prepare('DELETE FROM saved_assets WHERE id = ?'); + stmt.run(id); +} diff --git a/canvas/src/server/watcher.ts b/canvas/src/server/watcher.ts index 82a1f0a..8501952 100644 --- a/canvas/src/server/watcher.ts +++ b/canvas/src/server/watcher.ts @@ -26,6 +26,9 @@ import { getAnnotations, createCampaignWithCreations, getCampaignPreviewUrls, + getSavedAssets, + createSavedAsset, + deleteSavedAsset, } from './db-api'; // ─── Creation dimensions by type ──────────────────────────────────────────── @@ -562,6 +565,44 @@ export function fluidWatcherPlugin(workingDir: string): Plugin { return; } + // ── Saved assets (user library) ──────────────────────────────────── + + // GET /api/assets + if (url === '/api/assets' && method === 'GET') { + const assets = getSavedAssets(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(assets)); + return; + } + + // POST /api/assets + if (url === '/api/assets' && method === 'POST') { + const body = JSON.parse(await readBody(req)); + if (!body.url || typeof body.url !== 'string') { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'url is required' })); + return; + } + const asset = createSavedAsset({ + url: body.url, + name: body.name ?? null, + mimeType: body.mimeType ?? null, + source: body.source === 'upload' ? 'upload' : 'dam', + }); + res.writeHead(201, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(asset)); + return; + } + + // DELETE /api/assets/:id + const assetIdMatch = url.match(/^\/api\/assets\/([^/]+)$/); + if (assetIdMatch && method === 'DELETE') { + deleteSavedAsset(assetIdMatch[1]); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + return; + } + // GET /api/campaigns/:id const campaignIdMatch = url.match(/^\/api\/campaigns\/([^/]+)$/); if (campaignIdMatch && method === 'GET') { diff --git a/canvas/src/store/campaign.ts b/canvas/src/store/campaign.ts index 5b2a0a5..4e4e865 100644 --- a/canvas/src/store/campaign.ts +++ b/canvas/src/store/campaign.ts @@ -4,7 +4,7 @@ import type { Campaign, Creation, Slide, Iteration } from '../lib/campaign-types export type NavigationView = 'dashboard' | 'campaign' | 'creation' | 'slide'; /** Top-level navigation tabs controlling the main viewport */ -export type NavTab = 'create' | 'my-creations' | 'templates' | 'patterns' | 'voice-guide'; +export type NavTab = 'create' | 'my-creations' | 'assets' | 'templates' | 'patterns' | 'voice-guide'; /** Sub-tabs within the Create viewport */ export type CreateViewportTab = 'campaigns' | 'creations';