feat: full studio build -- light theme, canvas thumbnails, i18n (fa/en)
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { createVideoProject } from "@/lib/create-video-project";
|
||||
|
||||
import { SectionReveal } from "./SectionReveal";
|
||||
import { TemplateCard } from "./TemplateCard";
|
||||
import {
|
||||
FILTER_TABS,
|
||||
filterTemplates,
|
||||
getTemplateImageSrc,
|
||||
type FilterTab,
|
||||
type TemplateItem,
|
||||
} from "./template-gallery-data";
|
||||
|
||||
export interface TemplateGalleryProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TemplateGallery({ className }: TemplateGalleryProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("templates");
|
||||
const [activeTab, setActiveTab] = useState<FilterTab>("All");
|
||||
const [usingTemplateId, setUsingTemplateId] = useState<string | null>(null);
|
||||
const filtered = filterTemplates(activeTab);
|
||||
|
||||
/** Map filter tab key → translated label */
|
||||
const tabLabel: Record<FilterTab, string> = {
|
||||
All: t("tabAll"),
|
||||
Videos: t("tabVideos"),
|
||||
Images: t("tabImages"),
|
||||
"Social Media": t("tabSocial"),
|
||||
Business: t("tabBusiness"),
|
||||
};
|
||||
|
||||
const handleUseTemplate = useCallback(
|
||||
async (template: TemplateItem) => {
|
||||
if (usingTemplateId) return;
|
||||
setUsingTemplateId(template.id);
|
||||
|
||||
// Image templates → create an image project (future)
|
||||
// All others → video project
|
||||
const isImage = template.category === "Images";
|
||||
if (isImage) {
|
||||
router.push("/dashboard");
|
||||
setUsingTemplateId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await createVideoProject({ name: template.name });
|
||||
setUsingTemplateId(null);
|
||||
|
||||
if (!result.ok) {
|
||||
// Dev mode: Supabase not configured → go to new-project onboarding
|
||||
if (result.error.includes("Supabase is not configured")) {
|
||||
router.push("/studio/video/new");
|
||||
return;
|
||||
}
|
||||
// Any other failure (unauth, server error) → send to sign-in
|
||||
router.push(`/auth?next=${encodeURIComponent("/templates")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(`/studio/video/${result.projectId}`);
|
||||
},
|
||||
[router, usingTemplateId]
|
||||
);
|
||||
|
||||
return (
|
||||
<section
|
||||
id="templates"
|
||||
className={cn("scroll-mt-20 w-full bg-white py-20 sm:py-28", className)}
|
||||
>
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<SectionReveal>
|
||||
<h2 className="text-center font-heading text-3xl font-bold tracking-tight text-neutral-900 sm:text-4xl">
|
||||
{t("heading")}
|
||||
</h2>
|
||||
</SectionReveal>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<SectionReveal className="mt-10 flex flex-wrap items-center justify-center gap-2">
|
||||
{FILTER_TABS.map((tab) => {
|
||||
const isActive = activeTab === tab;
|
||||
return (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={cn(
|
||||
"relative rounded-lg px-4 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2",
|
||||
isActive
|
||||
? "text-white"
|
||||
: "text-neutral-600 hover:text-neutral-900"
|
||||
)}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.span
|
||||
layoutId="template-gallery-tab"
|
||||
className="absolute inset-0 rounded-lg bg-primary-600 shadow-sm"
|
||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10">{tabLabel[tab]}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</SectionReveal>
|
||||
|
||||
{/* Card grid — flex + justify-center so partial rows are centred */}
|
||||
<motion.div
|
||||
layout
|
||||
className="mt-12 flex flex-wrap justify-center gap-6"
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{filtered.map((template) => (
|
||||
<motion.div
|
||||
key={template.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 16, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -8, scale: 0.96 }}
|
||||
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||
className="w-full sm:w-[calc(50%-12px)] lg:w-[calc(25%-18px)] max-w-[320px]"
|
||||
>
|
||||
<TemplateCard
|
||||
templateId={template.id}
|
||||
name={template.name}
|
||||
category={template.category}
|
||||
imageSrc={getTemplateImageSrc(template.id)}
|
||||
previewVideoUrl={template.previewVideoUrl}
|
||||
previewSeed={template.id}
|
||||
priority={filtered.indexOf(template) < 4}
|
||||
onUseTemplate={() => void handleUseTemplate(template)}
|
||||
isUsingTemplate={usingTemplateId === template.id}
|
||||
useTemplateLabel={t("useTemplate")}
|
||||
openingLabel={t("opening")}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
<SectionReveal className="mt-12 text-center">
|
||||
<Link
|
||||
href="/templates"
|
||||
className="inline-flex items-center gap-1 text-sm font-semibold text-primary-600 transition-colors hover:text-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2 rounded-sm"
|
||||
>
|
||||
{t("browseAll")}
|
||||
<ArrowRight className="h-4 w-4" aria-hidden />
|
||||
</Link>
|
||||
</SectionReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user