Files
flatrender/src/lib/video-templates-catalog.ts
T
Soroush.Asadi 36e264f3e3 feat: admin API integration, LogoMark, settings page, i18n, RTL font, docs
- Wire admin API into homepage + templates page (ISR 60s, null fallback)
- Add src/lib/admin-api.ts with safeFetch helper
- Add adminProjectToTemplateItem + adminProjectToCatalogTemplate mappers
- Add LogoMark SVG component, replace Sparkles icon in Navbar/Footer/Sidebar
- Add public/favicon.svg (SVG brand mark)
- Rewrite opengraph-image.tsx with FlatRender branding
- Add RTL/Persian font cascade: unlayered [dir=rtl] block forces Vazirmatn
- Dashboard Settings page: Profile, Security, Billing, Notifications sections
- Add src/lib/supabase/client.ts browser client
- Admin API: GET /me, PATCH /profile, POST /change-password endpoints
- Admin API DTOs: AdminUserDto, UpdateProfileRequest, ChangePasswordRequest
- Admin UI Settings page with TanStack Query + mutations
- Add CLAUDE.md + README.md to both repos for new-machine onboarding
- Update PROJECT_MEMORY.md with session log
- Add appsettings.Development.json.example template
2026-05-27 09:06:51 +03:30

510 lines
13 KiB
TypeScript

export type VideoSidebarCategoryId =
| "all"
| "animation"
| "intros"
| "editing"
| "invitation"
| "holiday"
| "slideshow"
| "presentations"
| "social"
| "ads"
| "sales"
| "music";
export type AspectRatioFilter =
| "all"
| "widescreen"
| "portrait"
| "square"
| "fourFive";
export type DurationFilter = "all" | "flexible" | "fixed";
export type RefineType = "templates" | "packs";
export interface VideoSidebarCategory {
id: VideoSidebarCategoryId;
label: string;
}
export const VIDEO_SIDEBAR_CATEGORIES: VideoSidebarCategory[] = [
{ id: "all", label: "All Templates" },
{ id: "animation", label: "Animation Videos" },
{ id: "intros", label: "Intros and Logos" },
{ id: "editing", label: "Video Editing" },
{ id: "invitation", label: "Invitation Videos" },
{ id: "holiday", label: "Holiday Videos" },
{ id: "slideshow", label: "Slideshow" },
{ id: "presentations", label: "Presentations" },
{ id: "social", label: "Social Media Videos" },
{ id: "ads", label: "Video Ad Templates" },
{ id: "sales", label: "Sales Videos" },
{ id: "music", label: "Music Visualization" },
];
export const ASPECT_RATIO_OPTIONS: {
id: AspectRatioFilter;
label: string;
}[] = [
{ id: "all", label: "All Sizes" },
{ id: "widescreen", label: "16:9" },
{ id: "portrait", label: "9:16" },
{ id: "square", label: "1:1" },
{ id: "fourFive", label: "4:5" },
];
export type TemplateDetailAspectRatio = "16:9" | "9:16";
export const TEMPLATE_STYLE_COUNT = 4;
export interface VideoCatalogTemplate {
id: string;
name: string;
videoCategory: Exclude<VideoSidebarCategoryId, "all">;
aspectRatio: Exclude<AspectRatioFilter, "all">;
aspectRatios?: readonly TemplateDetailAspectRatio[];
durationType: "flexible" | "fixed";
premium: boolean;
sceneCount: number;
supports4k: boolean;
colorChange: boolean;
scriptToVideo: boolean;
description?: string;
isNew?: boolean;
}
export function getVideoTemplateCategoryLabel(
category: Exclude<VideoSidebarCategoryId, "all">
): string {
const match = VIDEO_SIDEBAR_CATEGORIES.find((item) => item.id === category);
return match?.label ?? category;
}
export function getTemplateDetailAspectRatios(
template: VideoCatalogTemplate
): TemplateDetailAspectRatio[] {
if (template.aspectRatios && template.aspectRatios.length > 0) {
return [...template.aspectRatios];
}
return ["16:9", "9:16"];
}
export function getVideoTemplateStyleImageSrc(
templateId: string,
styleIndex: number
): string {
return `https://picsum.photos/seed/${templateId}-style${styleIndex}/240/135`;
}
export function getVideoTemplateExampleImageSrc(
templateId: string,
exampleIndex: number
): string {
return `https://picsum.photos/seed/${templateId}-example${exampleIndex}/520/325`;
}
const templatesByCategory: Record<
Exclude<VideoSidebarCategoryId, "all">,
string[]
> = {
animation: [
"Whiteboard Animation Toolkit",
"3D Explainer Video Toolkit",
"Trendy Explainer Toolkit",
"Factory of 3D Animations",
"Anime Stories Pack",
"Healthcare Explainer Toolkit",
],
intros: [
"Abstract Distortion Intro",
"Glossy Bubbles Intro",
"Neon Soundwaves Visualizer",
"Minimal Logo Reveal",
"Glitch Intro Pack",
],
editing: [
"Cinematic Color Grade",
"Quick Cut Montage",
"Documentary Style Opener",
],
invitation: [
"Wedding Invitation Slideshow",
"Birthday Party Invite",
"Corporate Event Opening",
],
holiday: [
"Christmas Greeting Card",
"New Year Countdown",
"Seasonal Sale Promo",
],
slideshow: [
"Polaroid Frames Slideshow",
"Flipping Slideshow",
"Fragmented Transitions Slideshow",
"Parallax Circles",
"Bokeh Effects Slideshow",
],
presentations: [
"Business Presentation Pack",
"Startup Pitch Deck",
"Quarterly Report Intro",
],
social: [
"Instagram Carousel",
"TikTok Hook Pack",
"Story Highlight Reel",
"LinkedIn Promo",
],
ads: [
"Product Launch Ad",
"App Promo Vertical",
"Flash Sale Countdown",
],
sales: [
"SaaS Explainer",
"Real Estate Walkthrough",
"Restaurant Promo",
],
music: [
"Audio Spectrum Visualizer",
"Vinyl Record Spin",
"Beat Sync Typography",
],
};
const aspectRatios: Exclude<AspectRatioFilter, "all">[] = [
"widescreen",
"portrait",
"square",
"fourFive",
];
function buildVideoCatalog(): VideoCatalogTemplate[] {
const items: VideoCatalogTemplate[] = [];
let index = 0;
for (const [category, names] of Object.entries(templatesByCategory)) {
const videoCategory = category as Exclude<VideoSidebarCategoryId, "all">;
for (const baseName of names) {
for (let variant = 0; variant < 2; variant += 1) {
const name = variant > 0 ? `${baseName} ${variant + 1}` : baseName;
const detailAspectRatios: TemplateDetailAspectRatio[] =
index % 3 === 0 ? ["16:9"] : ["16:9", "9:16"];
items.push({
id: `vtpl-${category}-${index}`,
name,
videoCategory,
aspectRatio: aspectRatios[index % aspectRatios.length],
aspectRatios: detailAspectRatios,
durationType: index % 3 === 0 ? "fixed" : "flexible",
premium: index % 4 === 0,
sceneCount: 5 + (index % 12) * 10,
supports4k: index % 2 === 0,
colorChange: index % 3 !== 0,
scriptToVideo: index % 5 === 0,
isNew: index < 8,
});
index += 1;
}
}
}
return items;
}
/** Featured presets on /studio/video/new — ids match TEMPLATE_GALLERY_ITEMS */
const ONBOARDING_PRESET_TEMPLATES: VideoCatalogTemplate[] = [
{
id: "promo-reel",
name: "Animated Inspirational Video",
videoCategory: "animation",
aspectRatio: "widescreen",
aspectRatios: ["16:9", "9:16"],
durationType: "flexible",
premium: false,
sceneCount: 12,
supports4k: true,
colorChange: true,
scriptToVideo: false,
isNew: true,
},
{
id: "product-launch",
name: "Cybersecurity Company Promo",
videoCategory: "ads",
aspectRatio: "widescreen",
aspectRatios: ["16:9", "9:16"],
durationType: "flexible",
premium: true,
sceneCount: 8,
supports4k: true,
colorChange: true,
scriptToVideo: false,
},
{
id: "brand-story",
name: "Get to Know Your Customers Day",
videoCategory: "social",
aspectRatio: "widescreen",
aspectRatios: ["16:9", "9:16"],
durationType: "flexible",
premium: false,
sceneCount: 10,
supports4k: false,
colorChange: true,
scriptToVideo: true,
},
{
id: "instagram-carousel",
name: "SEO Agency Introduction",
videoCategory: "social",
aspectRatio: "square",
aspectRatios: ["16:9", "9:16"],
durationType: "flexible",
premium: false,
sceneCount: 6,
supports4k: false,
colorChange: true,
scriptToVideo: false,
},
{
id: "tiktok-hook",
name: "Tech Startup Promo",
videoCategory: "social",
aspectRatio: "portrait",
aspectRatios: ["9:16"],
durationType: "flexible",
premium: false,
sceneCount: 5,
supports4k: false,
colorChange: true,
scriptToVideo: false,
isNew: true,
},
{
id: "pitch-deck",
name: "Corporate Explainer",
videoCategory: "presentations",
aspectRatio: "widescreen",
aspectRatios: ["16:9"],
durationType: "fixed",
premium: false,
sceneCount: 15,
supports4k: true,
colorChange: true,
scriptToVideo: false,
},
{
id: "hero-promo",
name: "Hero Product Launch",
videoCategory: "ads",
aspectRatio: "widescreen",
aspectRatios: ["16:9", "9:16"],
durationType: "flexible",
premium: true,
sceneCount: 9,
supports4k: true,
colorChange: true,
scriptToVideo: false,
},
{
id: "event-recap",
name: "Event Recap Highlight",
videoCategory: "slideshow",
aspectRatio: "widescreen",
aspectRatios: ["16:9", "9:16"],
durationType: "flexible",
premium: false,
sceneCount: 11,
supports4k: true,
colorChange: true,
scriptToVideo: false,
},
];
export const VIDEO_TEMPLATES_CATALOG = [
...ONBOARDING_PRESET_TEMPLATES,
...buildVideoCatalog(),
];
export interface VideoTemplateFilters {
search: string;
sidebarCategory: VideoSidebarCategoryId;
aspectRatio: AspectRatioFilter;
duration: DurationFilter;
premiumOnly: boolean;
supports4k: boolean;
colorChange: boolean;
scriptToVideo: boolean;
}
export function filterVideoCatalog(
templates: VideoCatalogTemplate[],
filters: VideoTemplateFilters
): VideoCatalogTemplate[] {
const query = filters.search.trim().toLowerCase();
return templates.filter((template) => {
if (
filters.sidebarCategory !== "all" &&
template.videoCategory !== filters.sidebarCategory
) {
return false;
}
if (
filters.aspectRatio !== "all" &&
template.aspectRatio !== filters.aspectRatio
) {
return false;
}
if (
filters.duration !== "all" &&
template.durationType !== filters.duration
) {
return false;
}
if (filters.premiumOnly && !template.premium) return false;
if (filters.supports4k && !template.supports4k) return false;
if (filters.colorChange && !template.colorChange) return false;
if (filters.scriptToVideo && !template.scriptToVideo) return false;
if (query && !template.name.toLowerCase().includes(query)) return false;
return true;
});
}
export function getVideoTemplateImageSrc(id: string): string {
return `https://picsum.photos/seed/${id}/640/360`;
}
export interface VideoTemplateSection {
id: string;
title: string;
count: number;
templates: VideoCatalogTemplate[];
}
export function buildVideoTemplateSections(
filtered: VideoCatalogTemplate[],
sidebarCategory: VideoSidebarCategoryId
): VideoTemplateSection[] {
const newlyReleased = filtered.filter((t) => t.isNew).slice(0, 8);
const sections: VideoTemplateSection[] = [];
if (newlyReleased.length > 0 && sidebarCategory === "all") {
sections.push({
id: "newly-released",
title: "Newly released",
count: newlyReleased.length,
templates: newlyReleased,
});
}
const categories =
sidebarCategory === "all"
? VIDEO_SIDEBAR_CATEGORIES.filter((c) => c.id !== "all")
: VIDEO_SIDEBAR_CATEGORIES.filter((c) => c.id === sidebarCategory);
for (const category of categories) {
const templates = filtered
.filter((t) => t.videoCategory === category.id)
.slice(0, 12);
if (templates.length === 0) continue;
sections.push({
id: category.id,
title: category.label,
count: filtered.filter((t) => t.videoCategory === category.id).length,
templates,
});
}
return sections;
}
export function toProjectTemplate(
template: VideoCatalogTemplate
): { id: string; name: string; category: "Video" } {
return {
id: template.id,
name: template.name,
category: "Video",
};
}
// ── Admin API → catalog helpers ───────────────────────────────────────────────
/**
* Map an admin category name (or slug) to the closest hardcoded
* VideoSidebarCategoryId. Falls back to "social" when nothing matches.
*/
export function adminCategoryNameToSidebarId(
categoryName?: string
): Exclude<VideoSidebarCategoryId, "all"> {
if (!categoryName) return "social";
const n = categoryName.toLowerCase();
if (n.includes("animat")) return "animation";
if (n.includes("intro") || n.includes("logo")) return "intros";
if (n.includes("edit")) return "editing";
if (n.includes("invit")) return "invitation";
if (
n.includes("holiday") ||
n.includes("christmas") ||
n.includes("new year")
)
return "holiday";
if (n.includes("slide")) return "slideshow";
if (
n.includes("present") ||
n.includes("pitch") ||
n.includes("deck")
)
return "presentations";
if (
n.includes("social") ||
n.includes("instagram") ||
n.includes("tiktok") ||
n.includes("reel")
)
return "social";
if (n.includes("ad") || n.includes("promo") || n.includes("ads"))
return "ads";
if (n.includes("sale") || n.includes("real estate")) return "sales";
if (n.includes("music") || n.includes("audio")) return "music";
return "social";
}
/**
* Convert a raw AdminProject (from admin-api.ts) to a VideoCatalogTemplate
* so admin-managed templates can be shown on the templates page.
*
* Import type only — do not import from admin-api in this file at runtime.
*/
export interface AdminProjectLike {
slug: string;
title: string;
description?: string;
type: "video" | "image";
categoryName?: string;
coverImageUrl?: string;
previewVideoUrl?: string;
}
export function adminProjectToCatalogTemplate(
p: AdminProjectLike
): VideoCatalogTemplate {
return {
id: p.slug,
name: p.title,
videoCategory: adminCategoryNameToSidebarId(p.categoryName),
aspectRatio: "widescreen",
durationType: "flexible",
premium: false,
sceneCount: 0,
supports4k: false,
colorChange: false,
scriptToVideo: false,
description: p.description,
isNew: true,
};
}