diff --git a/api/index.py b/api/index.py index 6913327f..4d68b5e5 100644 --- a/api/index.py +++ b/api/index.py @@ -979,12 +979,21 @@ def _latest_compose_key(prefix: str, keys: set) -> Optional[str]: pass specs["artDirection"] = art_direction_content + # Confirmed template name from deck.json (written by the agent at Art Direction) + template = None + try: + dj_resp = s3_client.get_object(Bucket=BUCKET_NAME, Key=f"decks/{deck_id}/deck.json") + template = json.loads(dj_resp["Body"].read()).get("template") or None + except Exception: + pass + return { "deckId": deck_id, "name": deck.get("name", "Untitled"), "slideCount": len(slides), "slides": slides, "specs": specs, + "template": template, "defsUrl": preview_url(defs_key) if has_defs else None, "pptxUrl": (_cf_signed_url(pptx_key) or presigned_url(s3_client, BUCKET_NAME, pptx_key)) if pptx_key else None, "updatedAt": deck.get("updatedAt", ""), diff --git a/web-ui/messages/en.json b/web-ui/messages/en.json index 91a99bc4..7d508ba3 100644 --- a/web-ui/messages/en.json +++ b/web-ui/messages/en.json @@ -407,5 +407,12 @@ "pinAria": "Pin {name}", "unpinAria": "Unpin {name}", "custom": "Custom" + }, + "templatePicker": { + "sectionTitle": "Template", + "useTemplate": "Use this template", + "useTemplateAria": "Use the {name} template", + "currentLabel": "In use: {name}", + "custom": "Custom" } } diff --git a/web-ui/messages/ja.json b/web-ui/messages/ja.json index a88fdba5..822bcdf9 100644 --- a/web-ui/messages/ja.json +++ b/web-ui/messages/ja.json @@ -407,5 +407,12 @@ "pinAria": "{name} をピン留め", "unpinAria": "{name} のピン留めを解除", "custom": "カスタム" + }, + "templatePicker": { + "sectionTitle": "テンプレート", + "useTemplate": "このテンプレートを使う", + "useTemplateAria": "テンプレート {name} を使う", + "currentLabel": "使用中: {name}", + "custom": "カスタム" } } diff --git a/web-ui/src/app/(authenticated)/decks/page.tsx b/web-ui/src/app/(authenticated)/decks/page.tsx index b474d828..1ba90557 100644 --- a/web-ui/src/app/(authenticated)/decks/page.tsx +++ b/web-ui/src/app/(authenticated)/decks/page.tsx @@ -70,6 +70,15 @@ export default function DecksPage() { chatRef.current?.insertAtCursor(msg) }, [ws.deck?.specs?.artDirection]) + /** Handle inline template selection — insert message into chat input. + * isChange is true when deck.json already has a confirmed template. */ + const handleTemplateSelect = useCallback((name: string, isChange: boolean) => { + const msg = isChange + ? `I want to change the template to "${name}". ` + : `I'll use the "${name}" template. ` + chatRef.current?.insertAtCursor(msg) + }, []) + /* ── Render ── */ return ( void + /** Callback when user selects a template inline (isChange = template already confirmed). */ + onTemplateSelect?: (name: string, isChange: boolean) => void + /** Confirmed template from deck.json (raw value, e.g. "corporate.pptx"). */ + currentTemplate?: string | null /** Cognito ID token for style API calls. */ idToken?: string } -export function SlideCarousel({ slides, defsUrl, deckId, deckName, pptxUrl, isLoading, onSlideClick, scrollToSlide, onScrollComplete, headerActions, ownerAlias, specs, workflowPhase, onStyleSelect, idToken }: SlideCarouselProps) { +export function SlideCarousel({ slides, defsUrl, deckId, deckName, pptxUrl, isLoading, onSlideClick, scrollToSlide, onScrollComplete, headerActions, ownerAlias, specs, workflowPhase, onStyleSelect, onTemplateSelect, currentTemplate, idToken }: SlideCarouselProps) { const t = useTranslations("carousel") const slidesWithPreview = slides.filter((s) => s.previewUrl || s.composeUrl) - // eslint-disable-next-line no-console const slugs = slides.map(s => s.slug) - // eslint-disable-next-line no-console if (new Set(slugs).size !== slugs.length) console.warn("[SlideCarousel] duplicate slugs:", slugs) // Check compose URL duplicates across different slugs - // eslint-disable-next-line no-console const urlBySlug: Record = {} const dupUrls: string[] = [] for (const s of slidesWithPreview) { @@ -66,9 +66,7 @@ export function SlideCarousel({ slides, defsUrl, deckId, deckName, pptxUrl, isLo if (u && Object.values(urlBySlug).includes(u)) dupUrls.push(`${s.slug}→${u}`) if (u) urlBySlug[s.slug] = u } - // eslint-disable-next-line no-console if (dupUrls.length) console.warn("[SlideCarousel] same composeUrl used for multiple slides:", dupUrls, urlBySlug) - const auth = useAuth() const { viewMode, setViewMode } = usePreferences() const containerRef = useRef(null) @@ -476,6 +474,8 @@ export function SlideCarousel({ slides, defsUrl, deckId, deckName, pptxUrl, isLo specName={specTab.charAt(0).toUpperCase() + specTab.slice(1)} specKey={specTab} onStyleSelect={specTab === "artDirection" ? onStyleSelect : undefined} + onTemplateSelect={specTab === "artDirection" ? onTemplateSelect : undefined} + currentTemplate={specTab === "artDirection" ? currentTemplate : undefined} idToken={specTab === "artDirection" ? idToken : undefined} /> )} diff --git a/web-ui/src/components/deck/SpecMarkdownPreview.tsx b/web-ui/src/components/deck/SpecMarkdownPreview.tsx index f4274b88..e5e1954e 100644 --- a/web-ui/src/components/deck/SpecMarkdownPreview.tsx +++ b/web-ui/src/components/deck/SpecMarkdownPreview.tsx @@ -4,8 +4,10 @@ * SpecMarkdownPreview — Renders spec markdown content with editorial styling. * Outline uses the dedicated OutlineView timeline component. * Brief uses react-markdown with HEX color swatches. - * Art Direction renders HTML via sandboxed iframe with an inline - * style gallery (gallery → preview → result states). + * Art Direction renders a persistent template picker section (header-like, + * non-sticky) above an inline style gallery (gallery → preview → result + * states). Both sections share one scroll container but keep independent + * display states. * * @param props.content - Markdown or HTML string to render * @param props.specName - Name of the spec (for empty state) @@ -23,6 +25,7 @@ import { fetchStyles, fetchStyleHtml, pinStyle, type StyleEntry } from "@/servic import { OutlineView } from "./OutlineView" import { StyleSlidePreview } from "@/components/StyleSlidePreview" import { StyleCard } from "./StyleCard" +import { TemplatePickerSection } from "./TemplatePickerSection" import { BriefWaiting, OutlineWaiting, ArtDirectionWaiting } from "./SpecWaiting" import { useTranslations } from "next-intl" @@ -86,7 +89,7 @@ const specComponents = { }, } -export function SpecMarkdownPreview({ content, specName, specKey, onStyleSelect, idToken }: { content: string | null; specName: string; specKey?: string; onStyleSelect?: (name: string) => void; idToken?: string }) { +export function SpecMarkdownPreview({ content, specName, specKey, onStyleSelect, onTemplateSelect, currentTemplate, idToken }: { content: string | null; specName: string; specKey?: string; onStyleSelect?: (name: string) => void; onTemplateSelect?: (name: string, isChange: boolean) => void; currentTemplate?: string | null; idToken?: string }) { const t = useTranslations("stylePicker") // Hooks must be called unconditionally — before any early returns. @@ -169,11 +172,22 @@ export function SpecMarkdownPreview({ content, specName, specKey, onStyleSelect, return
} - // Art Direction: 3-state inline view + // Art Direction: template section (persistent) + style section (3-state) + // sharing one scroll container. The template picker is header-like and + // independent of the style state machine — it stays visible in all states. if (specKey === "artDirection") { + const wrap = (body: React.ReactNode) => ( +
+ {onTemplateSelect && ( + + )} +
{body}
+
+ ) + // Waiting state (no content, not browsing styles) if (!content && adMode === "result") { - return ( + return wrap(
@@ -199,8 +213,8 @@ export function SpecMarkdownPreview({ content, specName, specKey, onStyleSelect, const hasPins = pinnedStyles.length > 0 const unpinnedStyles = styles.filter(s => !s.pinned) - return ( -
+ return wrap( +
{/* Header */}
@@ -283,8 +297,8 @@ export function SpecMarkdownPreview({ content, specName, specKey, onStyleSelect, else { setPreview(null); setAdMode("gallery") } } - return ( -
+ return wrap( +
{/* Header */}
@@ -324,8 +338,8 @@ export function SpecMarkdownPreview({ content, specName, specKey, onStyleSelect, } // RESULT state (default when content exists) - return ( -
+ return wrap( +
{onStyleSelect && (
+ ) +} diff --git a/web-ui/src/services/deckService.ts b/web-ui/src/services/deckService.ts index 45ec26c7..809111b6 100644 --- a/web-ui/src/services/deckService.ts +++ b/web-ui/src/services/deckService.ts @@ -38,6 +38,8 @@ export interface DeckDetail { name: string slideOrder: string[] slides: SlidePreview[] + /** Confirmed template from deck.json (e.g. "corporate.pptx"), null when unconfirmed. */ + template?: string | null defsUrl?: string | null pptxUrl: string | null specs?: SpecFiles | null