feat(templates): aspect-ratio picker drives which variant is built

The detail page now loads a template's real published aspect variants (16:9/1:1/9:16)
from the content container and the preview chips select among them. Build now copies
the SELECTED variant's scene graph (passes that variant's content project UUID), not a
default. Selection is lifted to TemplateDetailContent and shared by the preview picker
and the build button; the preview box reflects the chosen aspect.

Verified on insta-promo (16:9 + a duplicated 1:1 variant): both chips render, and
building 1:1 copies the 1:1 project's scenes (1 scene, 6 fields).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-05 10:08:11 +03:30
parent 0ca11f19dd
commit 8ab86a5cc6
6 changed files with 94 additions and 14 deletions
+11 -2
View File
@@ -2,13 +2,16 @@ import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { TemplateDetailContent } from "@/components/templates/TemplateDetailContent"; import { TemplateDetailContent } from "@/components/templates/TemplateDetailContent";
import { fetchProject } from "@/lib/admin-api"; import { fetchProject, fetchTemplateVariants } from "@/lib/admin-api";
import { import {
adminProjectToCatalogTemplate, adminProjectToCatalogTemplate,
VIDEO_TEMPLATES_CATALOG, VIDEO_TEMPLATES_CATALOG,
type TemplateDetailAspectRatio,
type VideoCatalogTemplate, type VideoCatalogTemplate,
} from "@/lib/video-templates-catalog"; } from "@/lib/video-templates-catalog";
const SUPPORTED_ASPECTS = new Set<TemplateDetailAspectRatio>(["16:9", "1:1", "9:16"]);
interface TemplateDetailPageProps { interface TemplateDetailPageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
} }
@@ -20,7 +23,13 @@ interface TemplateDetailPageProps {
*/ */
async function resolveTemplate(id: string): Promise<VideoCatalogTemplate | null> { async function resolveTemplate(id: string): Promise<VideoCatalogTemplate | null> {
const admin = await fetchProject(id); const admin = await fetchProject(id);
if (admin) return adminProjectToCatalogTemplate(admin); if (admin) {
const base = adminProjectToCatalogTemplate(admin);
const variants = (await fetchTemplateVariants(id))
.filter((v) => SUPPORTED_ASPECTS.has(v.aspect as TemplateDetailAspectRatio))
.map((v) => ({ aspect: v.aspect as TemplateDetailAspectRatio, projectId: v.projectId }));
return { ...base, variants };
}
return VIDEO_TEMPLATES_CATALOG.find((item) => item.id === id) ?? null; return VIDEO_TEMPLATES_CATALOG.find((item) => item.id === id) ?? null;
} }
@@ -1,23 +1,39 @@
"use client"; "use client";
import { useState } from "react";
import { TemplateDetailBreadcrumb } from "@/components/templates/TemplateDetailBreadcrumb"; import { TemplateDetailBreadcrumb } from "@/components/templates/TemplateDetailBreadcrumb";
import { TemplateDetailExamples } from "@/components/templates/TemplateDetailExamples"; import { TemplateDetailExamples } from "@/components/templates/TemplateDetailExamples";
import { TemplateDetailInfo } from "@/components/templates/TemplateDetailInfo"; import { TemplateDetailInfo } from "@/components/templates/TemplateDetailInfo";
import { TemplateDetailPreview } from "@/components/templates/TemplateDetailPreview"; import { TemplateDetailPreview } from "@/components/templates/TemplateDetailPreview";
import type { VideoCatalogTemplate } from "@/lib/video-templates-catalog"; import {
getTemplateDetailAspectRatios,
type TemplateDetailAspectRatio,
type VideoCatalogTemplate,
} from "@/lib/video-templates-catalog";
interface TemplateDetailContentProps { interface TemplateDetailContentProps {
template: VideoCatalogTemplate; template: VideoCatalogTemplate;
} }
export function TemplateDetailContent({ template }: TemplateDetailContentProps) { export function TemplateDetailContent({ template }: TemplateDetailContentProps) {
// Selected aspect is shared so the preview picker drives which variant gets built.
const aspects = getTemplateDetailAspectRatios(template);
const [selectedAspect, setSelectedAspect] = useState<TemplateDetailAspectRatio>(
aspects[0] ?? "16:9"
);
return ( return (
<div className="mx-auto max-w-7xl px-4 py-8 lg:px-8 lg:py-12"> <div className="mx-auto max-w-7xl px-4 py-8 lg:px-8 lg:py-12">
<TemplateDetailBreadcrumb templateName={template.name} /> <TemplateDetailBreadcrumb templateName={template.name} />
<div className="mt-6 grid gap-10 lg:grid-cols-[3fr_2fr] lg:items-start"> <div className="mt-6 grid gap-10 lg:grid-cols-[3fr_2fr] lg:items-start">
<TemplateDetailPreview template={template} /> <TemplateDetailPreview
<TemplateDetailInfo template={template} /> template={template}
selectedAspect={selectedAspect}
onSelectAspect={setSelectedAspect}
/>
<TemplateDetailInfo template={template} selectedAspect={selectedAspect} />
</div> </div>
<TemplateDetailExamples templateId={template.id} /> <TemplateDetailExamples templateId={template.id} />
@@ -16,6 +16,7 @@ import {
getVideoTemplateStyleImageSrc, getVideoTemplateStyleImageSrc,
TEMPLATE_STYLE_COUNT, TEMPLATE_STYLE_COUNT,
toProjectTemplate, toProjectTemplate,
type TemplateDetailAspectRatio,
type VideoCatalogTemplate, type VideoCatalogTemplate,
} from "@/lib/video-templates-catalog"; } from "@/lib/video-templates-catalog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -29,9 +30,10 @@ const STYLE_LABEL_KEYS = [
interface TemplateDetailInfoProps { interface TemplateDetailInfoProps {
template: VideoCatalogTemplate; template: VideoCatalogTemplate;
selectedAspect: TemplateDetailAspectRatio;
} }
export function TemplateDetailInfo({ template }: TemplateDetailInfoProps) { export function TemplateDetailInfo({ template, selectedAspect }: TemplateDetailInfoProps) {
const t = useTranslations("auto.componentsTemplatesTemplateDetailInfo"); const t = useTranslations("auto.componentsTemplatesTemplateDetailInfo");
const router = useRouter(); const router = useRouter();
const [selectedStyle, setSelectedStyle] = useState(0); const [selectedStyle, setSelectedStyle] = useState(0);
@@ -44,7 +46,14 @@ export function TemplateDetailInfo({ template }: TemplateDetailInfoProps) {
const handleCreate = async () => { const handleCreate = async () => {
setIsCreating(true); setIsCreating(true);
const result = await createProjectFromTemplate(toProjectTemplate(template)); // Build the variant matching the selected aspect (its content project id); fall
// back to the template id (slug) which the API resolves to a default variant.
const variant = template.variants?.find((v) => v.aspect === selectedAspect);
const base = toProjectTemplate(template);
const result = await createProjectFromTemplate({
...base,
id: variant?.projectId ?? base.id,
});
if (!result.ok) { if (!result.ok) {
setIsCreating(false); setIsCreating(false);
@@ -73,6 +82,8 @@ export function TemplateDetailInfo({ template }: TemplateDetailInfoProps) {
<span>{categoryLabel}</span> <span>{categoryLabel}</span>
<StatDot /> <StatDot />
<span>{durationLabel}</span> <span>{durationLabel}</span>
<StatDot />
<span>{selectedAspect}</span>
</div> </div>
<TemplateDetailRating /> <TemplateDetailRating />
@@ -17,19 +17,35 @@ import { cn } from "@/lib/utils";
interface TemplateDetailPreviewProps { interface TemplateDetailPreviewProps {
template: VideoCatalogTemplate; template: VideoCatalogTemplate;
selectedAspect: TemplateDetailAspectRatio;
onSelectAspect: (aspect: TemplateDetailAspectRatio) => void;
} }
export function TemplateDetailPreview({ template }: TemplateDetailPreviewProps) { const ASPECT_BOX: Record<TemplateDetailAspectRatio, string> = {
"16:9": "aspect-video",
"1:1": "aspect-square mx-auto max-w-md",
"9:16": "aspect-[9/16] mx-auto max-w-[300px]",
};
export function TemplateDetailPreview({
template,
selectedAspect,
onSelectAspect,
}: TemplateDetailPreviewProps) {
const t = useTranslations("auto.componentsTemplatesTemplateDetailPreview"); const t = useTranslations("auto.componentsTemplatesTemplateDetailPreview");
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [selectedRatio, setSelectedRatio] = useState<TemplateDetailAspectRatio>("16:9");
const aspectOptions = getTemplateDetailAspectRatios(template); const aspectOptions = getTemplateDetailAspectRatios(template);
const posterSrc = getVideoTemplateImageSrc(template.id); const posterSrc = getVideoTemplateImageSrc(template.id);
const videoSrc = getTemplatePreviewVideoSrc(template.id); const videoSrc = getTemplatePreviewVideoSrc(template.id);
return ( return (
<div> <div>
<div className="relative aspect-video overflow-hidden rounded-2xl bg-gray-100 shadow-lg"> <div
className={cn(
"relative overflow-hidden rounded-2xl bg-gray-100 shadow-lg",
ASPECT_BOX[selectedAspect]
)}
>
{isPlaying ? ( {isPlaying ? (
<video <video
src={videoSrc} src={videoSrc}
@@ -60,15 +76,15 @@ export function TemplateDetailPreview({ template }: TemplateDetailPreviewProps)
</div> </div>
{aspectOptions.length > 1 ? ( {aspectOptions.length > 1 ? (
<div className="mt-4 flex gap-2"> <div className="mt-4 flex justify-center gap-2 lg:justify-start">
{aspectOptions.map((ratio) => ( {aspectOptions.map((ratio) => (
<button <button
key={ratio} key={ratio}
type="button" type="button"
onClick={() => setSelectedRatio(ratio)} onClick={() => onSelectAspect(ratio)}
className={cn( className={cn(
"rounded-lg border px-4 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2", "rounded-lg border px-4 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2",
selectedRatio === ratio selectedAspect === ratio
? "border-blue-600 text-blue-600" ? "border-blue-600 text-blue-600"
: "border-gray-200 text-gray-700 hover:border-gray-300" : "border-gray-200 text-gray-700 hover:border-gray-300"
)} )}
+14
View File
@@ -209,6 +209,20 @@ export async function fetchProject(slug: string): Promise<AdminProject | null> {
return c ? containerToAdminProject(c) : null; return c ? containerToAdminProject(c) : null;
} }
/** Published aspect-ratio variants of a template container (aspect + content
* project id). Used to drive the detail page's aspect picker + which variant the
* studio copies. Returns [] when none / unreachable. */
export async function fetchTemplateVariants(
slug: string
): Promise<Array<{ aspect: string; projectId: string }>> {
const c = await safeGet<{
projects?: Array<{ id?: string; aspect?: string; is_published?: boolean }>;
}>(`/v1/templates/${encodeURIComponent(slug)}`);
return (c?.projects ?? [])
.filter((p) => p?.id && p?.is_published && p?.aspect)
.map((p) => ({ aspect: p.aspect as string, projectId: p.id as string }));
}
/** True when the gateway content endpoint is reachable. */ /** True when the gateway content endpoint is reachable. */
export async function isAdminApiAvailable(): Promise<boolean> { export async function isAdminApiAvailable(): Promise<boolean> {
try { try {
+15 -1
View File
@@ -56,7 +56,14 @@ export const ASPECT_RATIO_OPTIONS: {
{ id: "fourFive", label: "4:5" }, { id: "fourFive", label: "4:5" },
]; ];
export type TemplateDetailAspectRatio = "16:9" | "9:16"; export type TemplateDetailAspectRatio = "16:9" | "1:1" | "9:16";
/** A concrete aspect-ratio variant of a template — maps an aspect to the content
* project (UUID) the studio copies its scenes from. */
export interface TemplateVariant {
aspect: TemplateDetailAspectRatio;
projectId: string;
}
export const TEMPLATE_STYLE_COUNT = 4; export const TEMPLATE_STYLE_COUNT = 4;
@@ -66,6 +73,9 @@ export interface VideoCatalogTemplate {
videoCategory: Exclude<VideoSidebarCategoryId, "all">; videoCategory: Exclude<VideoSidebarCategoryId, "all">;
aspectRatio: Exclude<AspectRatioFilter, "all">; aspectRatio: Exclude<AspectRatioFilter, "all">;
aspectRatios?: readonly TemplateDetailAspectRatio[]; aspectRatios?: readonly TemplateDetailAspectRatio[];
/** Real aspect variants (with the content project id to copy). Empty for demo
* templates. When present, drives the aspect picker + which variant is built. */
variants?: TemplateVariant[];
durationType: "flexible" | "fixed"; durationType: "flexible" | "fixed";
premium: boolean; premium: boolean;
sceneCount: number; sceneCount: number;
@@ -86,6 +96,10 @@ export function getVideoTemplateCategoryLabel(
export function getTemplateDetailAspectRatios( export function getTemplateDetailAspectRatios(
template: VideoCatalogTemplate template: VideoCatalogTemplate
): TemplateDetailAspectRatio[] { ): TemplateDetailAspectRatio[] {
// Real variants take precedence so the picker reflects what can actually be built.
if (template.variants && template.variants.length > 0) {
return template.variants.map((v) => v.aspect);
}
if (template.aspectRatios && template.aspectRatios.length > 0) { if (template.aspectRatios && template.aspectRatios.length > 0) {
return [...template.aspectRatios]; return [...template.aspectRatios];
} }