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
This commit is contained in:
@@ -1,25 +1,68 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { SettingsBilling } from "@/components/dashboard/settings/SettingsBilling";
|
||||
import { SettingsNotifications } from "@/components/dashboard/settings/SettingsNotifications";
|
||||
import { SettingsProfile } from "@/components/dashboard/settings/SettingsProfile";
|
||||
import { SettingsSecurity } from "@/components/dashboard/settings/SettingsSecurity";
|
||||
import { createPageMetadata } from "@/lib/metadata";
|
||||
import { getUserProfile } from "@/lib/profiles";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
export const metadata: Metadata = createPageMetadata({
|
||||
title: "Settings",
|
||||
description: "Manage your CreatorStudio account and workspace preferences.",
|
||||
description: "Manage your FlatRender account and workspace preferences.",
|
||||
path: "/dashboard/settings",
|
||||
});
|
||||
|
||||
export default function DashboardSettingsPage() {
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function DashboardSettingsPage() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
const email = user?.email ?? "";
|
||||
const displayName =
|
||||
typeof user?.user_metadata?.full_name === "string"
|
||||
? user.user_metadata.full_name
|
||||
: null;
|
||||
|
||||
const profile = user ? await getUserProfile(user.id) : null;
|
||||
const plan = profile?.plan ?? "free";
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
{/* Page header */}
|
||||
<header className="border-b border-gray-100 bg-white px-6 py-4">
|
||||
<h1 className="font-heading text-xl font-bold text-neutral-900">
|
||||
Settings
|
||||
</h1>
|
||||
</header>
|
||||
<div className="flex-1 p-6">
|
||||
<p className="text-sm text-neutral-600">
|
||||
Account and workspace settings will be available here soon.
|
||||
<h1 className="font-heading text-xl font-bold text-neutral-900">Settings</h1>
|
||||
<p className="mt-0.5 text-sm text-neutral-500">
|
||||
Manage your account, security, and notification preferences.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 p-6">
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<SettingsProfile email={email} displayName={displayName} />
|
||||
<SettingsSecurity />
|
||||
<SettingsBilling plan={plan} />
|
||||
<SettingsNotifications />
|
||||
|
||||
{/* Danger zone */}
|
||||
<div className="rounded-xl border border-red-100 bg-white p-6">
|
||||
<h2 className="font-heading text-base font-semibold text-red-600">Danger zone</h2>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
Permanently delete your account and all your projects. This cannot be undone.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-4 rounded-lg border border-red-200 px-4 py-2 text-sm font-semibold text-red-600 transition-colors hover:bg-red-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2"
|
||||
>
|
||||
Delete account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -96,6 +96,7 @@ export default async function LocaleLayout({
|
||||
className={fontVars}
|
||||
>
|
||||
<head>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link
|
||||
rel="preconnect"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { TemplateGallery } from "@/components/sections/TemplateGallery";
|
||||
import { FAQ } from "@/components/sections/FAQ";
|
||||
import { Testimonials } from "@/components/sections/Testimonials";
|
||||
import { createPageMetadata } from "@/lib/metadata";
|
||||
import { fetchProjects } from "@/lib/admin-api";
|
||||
|
||||
export const metadata: Metadata = createPageMetadata({
|
||||
title: "Create Pro Videos & Images with AI",
|
||||
@@ -16,12 +17,17 @@ export const metadata: Metadata = createPageMetadata({
|
||||
path: "/",
|
||||
});
|
||||
|
||||
export default function Home() {
|
||||
export default async function Home() {
|
||||
// Fetch up to 8 published projects from the admin service.
|
||||
// Returns an empty array when ADMIN_API_URL is not set or the service
|
||||
// is unreachable — TemplateGallery falls back to hardcoded data.
|
||||
const { items: adminProjects } = await fetchProjects({ pageSize: 8 });
|
||||
|
||||
return (
|
||||
<main>
|
||||
<Hero />
|
||||
<ProductsShowcase />
|
||||
<TemplateGallery />
|
||||
<TemplateGallery adminItems={adminProjects} />
|
||||
<HowItWorks />
|
||||
<Pricing />
|
||||
<Testimonials />
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
|
||||
|
||||
import { TemplatesPageContent } from "@/components/templates/TemplatesPageContent";
|
||||
import { createPageMetadata } from "@/lib/metadata";
|
||||
import { fetchProjects } from "@/lib/admin-api";
|
||||
import { adminProjectToCatalogTemplate } from "@/lib/video-templates-catalog";
|
||||
|
||||
export const metadata: Metadata = createPageMetadata({
|
||||
title: "Video Templates",
|
||||
@@ -10,10 +12,23 @@ export const metadata: Metadata = createPageMetadata({
|
||||
path: "/templates",
|
||||
});
|
||||
|
||||
export default function TemplatesPage() {
|
||||
export default async function TemplatesPage() {
|
||||
// Fetch video projects from the admin service.
|
||||
// When ADMIN_API_URL is not set or the service is unreachable this returns
|
||||
// an empty array → VideoTemplatesPageContent falls back to the demo catalog.
|
||||
const { items: adminProjects } = await fetchProjects({
|
||||
type: "video",
|
||||
pageSize: 100,
|
||||
});
|
||||
|
||||
const initialCatalog =
|
||||
adminProjects.length > 0
|
||||
? adminProjects.map(adminProjectToCatalogTemplate)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-white">
|
||||
<TemplatesPageContent />
|
||||
<TemplatesPageContent initialCatalog={initialCatalog} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ── RTL / Persian font override ─────────────────────────────────
|
||||
Ensures Vazirmatn is used for every text element regardless of
|
||||
any utility class or CSS-variable fallback chain. */
|
||||
[dir="rtl"],
|
||||
[dir="rtl"] body,
|
||||
[dir="rtl"] h1,
|
||||
[dir="rtl"] h2,
|
||||
[dir="rtl"] h3,
|
||||
[dir="rtl"] h4,
|
||||
[dir="rtl"] h5,
|
||||
[dir="rtl"] h6,
|
||||
[dir="rtl"] p,
|
||||
[dir="rtl"] span,
|
||||
[dir="rtl"] a,
|
||||
[dir="rtl"] button,
|
||||
[dir="rtl"] input,
|
||||
[dir="rtl"] textarea,
|
||||
[dir="rtl"] select,
|
||||
[dir="rtl"] label {
|
||||
font-family: var(--font-vazirmatn), "Vazirmatn", sans-serif;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.bg-checkerboard {
|
||||
background-color: #1f2937;
|
||||
|
||||
+73
-20
@@ -2,7 +2,7 @@ import { ImageResponse } from "next/og";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
export const alt = "CreatorStudio — AI Video & Image Maker";
|
||||
export const alt = "FlatRender — AI Video & Image Maker";
|
||||
export const size = { width: 1200, height: 630 };
|
||||
export const contentType = "image/png";
|
||||
|
||||
@@ -17,41 +17,94 @@ export default function OpenGraphImage() {
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "center",
|
||||
background: "linear-gradient(135deg, #1e40af 0%, #2563EB 50%, #7c3aed 100%)",
|
||||
padding: "80px",
|
||||
background:
|
||||
"linear-gradient(135deg, #1e3a8a 0%, #2563EB 55%, #4f46e5 100%)",
|
||||
padding: "80px 90px",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 28,
|
||||
fontWeight: 600,
|
||||
color: "rgba(255,255,255,0.85)",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
CreatorStudio
|
||||
{/* Logo mark row */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16, marginBottom: 40 }}>
|
||||
{/* Icon */}
|
||||
<div
|
||||
style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 12,
|
||||
background: "rgba(255,255,255,0.15)",
|
||||
border: "1.5px solid rgba(255,255,255,0.3)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{/* Play triangle */}
|
||||
<div
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderTop: "11px solid transparent",
|
||||
borderBottom: "11px solid transparent",
|
||||
borderLeft: "18px solid white",
|
||||
marginLeft: 4,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
style={{ fontSize: 28, fontWeight: 700, color: "white", letterSpacing: -0.5 }}
|
||||
>
|
||||
FlatRender
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Main headline */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 64,
|
||||
fontWeight: 700,
|
||||
fontSize: 62,
|
||||
fontWeight: 800,
|
||||
color: "white",
|
||||
lineHeight: 1.1,
|
||||
lineHeight: 1.08,
|
||||
maxWidth: 900,
|
||||
letterSpacing: -1.5,
|
||||
}}
|
||||
>
|
||||
Create pro videos & images with AI
|
||||
Create pro videos & images with AI
|
||||
</div>
|
||||
|
||||
{/* Subtitle */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 28,
|
||||
color: "rgba(255,255,255,0.9)",
|
||||
marginTop: 24,
|
||||
maxWidth: 800,
|
||||
fontSize: 26,
|
||||
color: "rgba(255,255,255,0.82)",
|
||||
marginTop: 28,
|
||||
maxWidth: 760,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
Templates, editors, and one-click export for every channel
|
||||
</div>
|
||||
|
||||
{/* Bottom pill tags */}
|
||||
<div style={{ display: "flex", gap: 12, marginTop: 48 }}>
|
||||
{["Video Maker", "Image Maker", "AI Templates", "One-click Export"].map(
|
||||
(tag) => (
|
||||
<div
|
||||
key={tag}
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.15)",
|
||||
border: "1px solid rgba(255,255,255,0.25)",
|
||||
borderRadius: 100,
|
||||
padding: "8px 20px",
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
color: "rgba(255,255,255,0.9)",
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{ ...size }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { LogoMark } from "@/components/ui/LogoMark";
|
||||
|
||||
import {
|
||||
DashboardPlanBadge,
|
||||
@@ -40,9 +40,7 @@ export function DashboardSidebar({
|
||||
href="/"
|
||||
className="flex items-center gap-2 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2"
|
||||
>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary-600 text-white">
|
||||
<Sparkles className="h-5 w-5" aria-hidden />
|
||||
</span>
|
||||
<LogoMark size={36} />
|
||||
<span className="font-heading text-lg font-bold text-neutral-900">
|
||||
FlatRender
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { CreditCard, ExternalLink, Zap } from "lucide-react";
|
||||
|
||||
import type { PlanId } from "@/lib/plans";
|
||||
|
||||
interface SettingsBillingProps {
|
||||
plan: PlanId;
|
||||
}
|
||||
|
||||
const PLAN_LABELS: Record<PlanId, string> = {
|
||||
free: "Free",
|
||||
pro: "Pro",
|
||||
business: "Business",
|
||||
};
|
||||
|
||||
const PLAN_COLORS: Record<PlanId, string> = {
|
||||
free: "bg-neutral-100 text-neutral-600",
|
||||
pro: "bg-indigo-50 text-indigo-700",
|
||||
business: "bg-violet-50 text-violet-700",
|
||||
};
|
||||
|
||||
const PLAN_FEATURES: Record<PlanId, string[]> = {
|
||||
free: ["5 projects", "720p export", "Community templates"],
|
||||
pro: ["Unlimited projects", "4K export", "All templates", "Priority render queue", "Custom fonts"],
|
||||
business: ["Everything in Pro", "Team seats", "White-label export", "API access", "Dedicated support"],
|
||||
};
|
||||
|
||||
export function SettingsBilling({ plan }: SettingsBillingProps) {
|
||||
const isPaid = plan !== "free";
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-6">
|
||||
<h2 className="font-heading text-base font-semibold text-neutral-900">Billing & Plan</h2>
|
||||
<p className="mt-1 text-sm text-neutral-500">Manage your subscription and payment method.</p>
|
||||
|
||||
{/* Current plan card */}
|
||||
<div className="mt-6 rounded-lg border border-gray-100 bg-neutral-50 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-100 text-primary-600">
|
||||
<Zap className="h-5 w-5" aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-neutral-500">Current plan</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-heading text-lg font-bold text-neutral-900">{PLAN_LABELS[plan]}</p>
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${PLAN_COLORS[plan]}`}>
|
||||
{isPaid ? "Active" : "Free tier"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isPaid ? (
|
||||
<a
|
||||
href="/api/billing/portal"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
>
|
||||
<CreditCard className="h-4 w-4" aria-hidden />
|
||||
Manage billing
|
||||
<ExternalLink className="h-3 w-3" aria-hidden />
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
href="/#pricing"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
>
|
||||
<Zap className="h-4 w-4" aria-hidden />
|
||||
Upgrade
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Features list */}
|
||||
<ul className="mt-4 space-y-1.5">
|
||||
{PLAN_FEATURES[plan].map((f) => (
|
||||
<li key={f} className="flex items-center gap-2 text-sm text-neutral-600">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-primary-500" aria-hidden />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{!isPaid && (
|
||||
<p className="mt-4 text-xs text-neutral-400">
|
||||
Upgrade to unlock unlimited projects, 4K export, and premium templates.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
interface Toggle {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
defaultOn: boolean;
|
||||
}
|
||||
|
||||
const TOGGLES: Toggle[] = [
|
||||
{
|
||||
id: "render-complete",
|
||||
label: "Render complete",
|
||||
description: "Get notified when your video export finishes.",
|
||||
defaultOn: true,
|
||||
},
|
||||
{
|
||||
id: "project-shared",
|
||||
label: "Project shared with you",
|
||||
description: "When a team member shares a project.",
|
||||
defaultOn: true,
|
||||
},
|
||||
{
|
||||
id: "weekly-digest",
|
||||
label: "Weekly digest",
|
||||
description: "Summary of new templates and platform updates.",
|
||||
defaultOn: false,
|
||||
},
|
||||
{
|
||||
id: "product-news",
|
||||
label: "Product news",
|
||||
description: "New features, tips, and announcements.",
|
||||
defaultOn: false,
|
||||
},
|
||||
];
|
||||
|
||||
export function SettingsNotifications() {
|
||||
const [prefs, setPrefs] = useState<Record<string, boolean>>(
|
||||
Object.fromEntries(TOGGLES.map((t) => [t.id, t.defaultOn]))
|
||||
);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
function toggle(id: string) {
|
||||
setPrefs((p) => ({ ...p, [id]: !p[id] }));
|
||||
setSaved(false);
|
||||
}
|
||||
|
||||
function save() {
|
||||
// In production: POST /api/user/notification-prefs
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2500);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-6">
|
||||
<h2 className="font-heading text-base font-semibold text-neutral-900">Notifications</h2>
|
||||
<p className="mt-1 text-sm text-neutral-500">Choose which emails you receive from FlatRender.</p>
|
||||
|
||||
<div className="mt-6 divide-y divide-gray-100">
|
||||
{TOGGLES.map((item) => (
|
||||
<div key={item.id} className="flex items-start justify-between gap-4 py-4 first:pt-0 last:pb-0">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-900">{item.label}</p>
|
||||
<p className="mt-0.5 text-xs text-neutral-500">{item.description}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={prefs[item.id]}
|
||||
onClick={() => toggle(item.id)}
|
||||
className={`relative mt-0.5 h-6 w-11 shrink-0 rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 ${
|
||||
prefs[item.id] ? "bg-primary-600" : "bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow-sm transition-transform ${
|
||||
prefs[item.id] ? "translate-x-[22px]" : "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
|
||||
>
|
||||
Save preferences
|
||||
</button>
|
||||
{saved && <span className="text-sm text-green-600">Saved!</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { User } from "lucide-react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
|
||||
interface SettingsProfileProps {
|
||||
email: string;
|
||||
displayName: string | null;
|
||||
}
|
||||
|
||||
export function SettingsProfile({ email, displayName }: SettingsProfileProps) {
|
||||
const [name, setName] = useState(displayName ?? "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
|
||||
const initials = (displayName ?? email).slice(0, 2).toUpperCase();
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
const supabase = createClient();
|
||||
const { error } = await supabase.auth.updateUser({ data: { full_name: name.trim() } });
|
||||
setSaving(false);
|
||||
if (error) {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
} else {
|
||||
setMessage({ type: "success", text: "Profile updated successfully." });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-6">
|
||||
<h2 className="font-heading text-base font-semibold text-neutral-900">Profile</h2>
|
||||
<p className="mt-1 text-sm text-neutral-500">Your public name and account email.</p>
|
||||
|
||||
<div className="mt-6 flex items-center gap-4">
|
||||
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-primary-100 font-heading text-xl font-bold text-primary-700">
|
||||
{initials}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-neutral-900">{displayName ?? email.split("@")[0]}</p>
|
||||
<p className="text-sm text-neutral-500">{email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={(e) => void handleSave(e)} className="mt-6 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="display-name" className="block text-sm font-medium text-neutral-700">
|
||||
Display name
|
||||
</label>
|
||||
<input
|
||||
id="display-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Your name"
|
||||
className="mt-1.5 block w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700">Email</label>
|
||||
<div className="mt-1.5 flex items-center gap-2 rounded-lg border border-gray-200 bg-neutral-50 px-3 py-2">
|
||||
<User className="h-4 w-4 text-neutral-400" aria-hidden />
|
||||
<span className="text-sm text-neutral-500">{email}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-neutral-400">Email cannot be changed here. Contact support.</p>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<p className={`text-sm ${message.type === "success" ? "text-green-600" : "text-red-600"}`}>
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-primary-700 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
|
||||
>
|
||||
{saving ? "Saving…" : "Save changes"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
|
||||
export function SettingsSecurity() {
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [showPw, setShowPw] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
|
||||
if (next.length < 8) {
|
||||
setMessage({ type: "error", text: "New password must be at least 8 characters." });
|
||||
return;
|
||||
}
|
||||
if (next !== confirm) {
|
||||
setMessage({ type: "error", text: "Passwords do not match." });
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
const supabase = createClient();
|
||||
|
||||
// Re-authenticate with current password first
|
||||
const { data: session } = await supabase.auth.getSession();
|
||||
const email = session.session?.user?.email;
|
||||
if (!email) {
|
||||
setMessage({ type: "error", text: "Session expired. Please sign in again." });
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { error: signInError } = await supabase.auth.signInWithPassword({ email, password: current });
|
||||
if (signInError) {
|
||||
setSaving(false);
|
||||
setMessage({ type: "error", text: "Current password is incorrect." });
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await supabase.auth.updateUser({ password: next });
|
||||
setSaving(false);
|
||||
|
||||
if (error) {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
} else {
|
||||
setMessage({ type: "success", text: "Password changed successfully." });
|
||||
setCurrent(""); setNext(""); setConfirm("");
|
||||
}
|
||||
}
|
||||
|
||||
function PwInput({ id, label, value, onChange }: { id: string; label: string; value: string; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={id} className="block text-sm font-medium text-neutral-700">{label}</label>
|
||||
<div className="relative mt-1.5">
|
||||
<input
|
||||
id={id}
|
||||
type={showPw ? "text" : "password"}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
required
|
||||
className="block w-full rounded-lg border border-gray-200 px-3 py-2 pr-10 text-sm text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPw((v) => !v)}
|
||||
className="absolute inset-y-0 right-2 flex items-center text-neutral-400 hover:text-neutral-600"
|
||||
aria-label={showPw ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPw ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-6">
|
||||
<h2 className="font-heading text-base font-semibold text-neutral-900">Security</h2>
|
||||
<p className="mt-1 text-sm text-neutral-500">Change your account password.</p>
|
||||
|
||||
<form onSubmit={(e) => void handleSubmit(e)} className="mt-6 space-y-4">
|
||||
<PwInput id="current-pw" label="Current password" value={current} onChange={setCurrent} />
|
||||
<PwInput id="new-pw" label="New password" value={next} onChange={setNext} />
|
||||
<PwInput id="confirm-pw" label="Confirm new password" value={confirm} onChange={setConfirm} />
|
||||
|
||||
{message && (
|
||||
<p className={`text-sm ${message.type === "success" ? "text-green-600" : "text-red-600"}`}>
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-primary-700 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
|
||||
>
|
||||
{saving ? "Saving…" : "Change password"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,9 +5,9 @@ import {
|
||||
CirclePlay,
|
||||
Link as LinkIcon,
|
||||
Share2,
|
||||
Sparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { LogoMark } from "@/components/ui/LogoMark";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -82,9 +82,7 @@ export function Footer() {
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-900"
|
||||
>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary-600 text-white">
|
||||
<Sparkles className="h-5 w-5" aria-hidden />
|
||||
</span>
|
||||
<LogoMark size={36} />
|
||||
<span className="font-heading text-lg font-bold text-white">
|
||||
{t("brandName")}
|
||||
</span>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Menu, Sparkles } from "lucide-react";
|
||||
import { Menu } from "lucide-react";
|
||||
import { LogoMark } from "@/components/ui/LogoMark";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import {
|
||||
@@ -74,9 +75,7 @@ export function Navbar() {
|
||||
className="flex shrink-0 items-center gap-2 rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2"
|
||||
aria-label={t("ariaLabel")}
|
||||
>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-blue-600">
|
||||
<Sparkles className="h-5 w-5 text-white" aria-hidden />
|
||||
</span>
|
||||
<LogoMark size={36} />
|
||||
<span className="font-heading text-lg font-bold text-neutral-900">
|
||||
{t("brandName")}
|
||||
</span>
|
||||
|
||||
@@ -9,27 +9,65 @@ import { useTranslations } from "next-intl";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { createVideoProject } from "@/lib/create-video-project";
|
||||
import type { AdminProject } from "@/lib/admin-api";
|
||||
|
||||
import { SectionReveal } from "./SectionReveal";
|
||||
import { TemplateCard } from "./TemplateCard";
|
||||
import {
|
||||
FILTER_TABS,
|
||||
TEMPLATES,
|
||||
filterTemplates,
|
||||
getTemplateImageSrc,
|
||||
type FilterTab,
|
||||
type TemplateCategory,
|
||||
type TemplateItem,
|
||||
} from "./template-gallery-data";
|
||||
|
||||
export interface TemplateGalleryProps {
|
||||
className?: string;
|
||||
const VALID_CATEGORIES = new Set<TemplateCategory>([
|
||||
"Videos",
|
||||
"Images",
|
||||
"Social Media",
|
||||
"Business",
|
||||
]);
|
||||
|
||||
function adminProjectToTemplateItem(p: AdminProject): TemplateItem {
|
||||
let category: TemplateCategory;
|
||||
if (
|
||||
p.categoryName &&
|
||||
VALID_CATEGORIES.has(p.categoryName as TemplateCategory)
|
||||
) {
|
||||
category = p.categoryName as TemplateCategory;
|
||||
} else {
|
||||
category = p.type === "video" ? "Videos" : "Images";
|
||||
}
|
||||
return {
|
||||
id: p.slug,
|
||||
name: p.title,
|
||||
category,
|
||||
previewVideoUrl: p.previewVideoUrl ?? undefined,
|
||||
imageSrc: p.coverImageUrl ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function TemplateGallery({ className }: TemplateGalleryProps) {
|
||||
export interface TemplateGalleryProps {
|
||||
className?: string;
|
||||
/** Live projects from the admin API. Falls back to hardcoded list when empty. */
|
||||
adminItems?: AdminProject[];
|
||||
}
|
||||
|
||||
export function TemplateGallery({ className, adminItems }: 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);
|
||||
|
||||
// Use admin items when the service returned data; fall back to hardcoded list
|
||||
const allItems: TemplateItem[] =
|
||||
adminItems && adminItems.length > 0
|
||||
? adminItems.map(adminProjectToTemplateItem)
|
||||
: TEMPLATES;
|
||||
|
||||
const filtered = filterTemplates(activeTab, allItems);
|
||||
|
||||
/** Map filter tab key → translated label */
|
||||
const tabLabel: Record<FilterTab, string> = {
|
||||
@@ -134,7 +172,7 @@ export function TemplateGallery({ className }: TemplateGalleryProps) {
|
||||
templateId={template.id}
|
||||
name={template.name}
|
||||
category={template.category}
|
||||
imageSrc={getTemplateImageSrc(template.id)}
|
||||
imageSrc={template.imageSrc ?? getTemplateImageSrc(template.id)}
|
||||
previewVideoUrl={template.previewVideoUrl}
|
||||
previewSeed={template.id}
|
||||
priority={filtered.indexOf(template) < 4}
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface TemplateItem {
|
||||
category: TemplateCategory;
|
||||
/** Mixkit CDN clip for hover preview on landing gallery cards */
|
||||
previewVideoUrl?: string;
|
||||
/** Cover image — overrides the picsum fallback when set (e.g. from admin API) */
|
||||
imageSrc?: string;
|
||||
}
|
||||
|
||||
const MIXKIT = {
|
||||
@@ -67,9 +69,12 @@ export const TEMPLATES: TemplateItem[] = [
|
||||
{ id: "pitch-deck", name: "Pitch Deck", category: "Business" },
|
||||
];
|
||||
|
||||
export function filterTemplates(tab: FilterTab): TemplateItem[] {
|
||||
if (tab === "All") return TEMPLATES;
|
||||
return TEMPLATES.filter((template) => template.category === tab);
|
||||
export function filterTemplates(
|
||||
tab: FilterTab,
|
||||
items: TemplateItem[] = TEMPLATES
|
||||
): TemplateItem[] {
|
||||
if (tab === "All") return items;
|
||||
return items.filter((template) => template.category === tab);
|
||||
}
|
||||
|
||||
export function getTemplateImageSrc(id: string): string {
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { VideoTemplatesPageContent } from "@/components/templates/video/VideoTemplatesPageContent";
|
||||
import {
|
||||
VideoTemplatesPageContent,
|
||||
type VideoTemplatesPageContentProps,
|
||||
} from "@/components/templates/video/VideoTemplatesPageContent";
|
||||
|
||||
function TemplatesPageFallback() {
|
||||
return (
|
||||
@@ -17,10 +20,12 @@ function TemplatesPageFallback() {
|
||||
);
|
||||
}
|
||||
|
||||
export function TemplatesPageContent() {
|
||||
export function TemplatesPageContent({
|
||||
initialCatalog,
|
||||
}: VideoTemplatesPageContentProps = {}) {
|
||||
return (
|
||||
<Suspense fallback={<TemplatesPageFallback />}>
|
||||
<VideoTemplatesPageContent />
|
||||
<VideoTemplatesPageContent initialCatalog={initialCatalog} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,18 @@ import {
|
||||
type VideoSidebarCategoryId,
|
||||
} from "@/lib/video-templates-catalog";
|
||||
|
||||
export function VideoTemplatesPageContent() {
|
||||
export interface VideoTemplatesPageContentProps {
|
||||
/**
|
||||
* Admin-sourced catalog. When non-empty the page shows live templates
|
||||
* instead of the hardcoded demo catalog, while keeping all client-side
|
||||
* filtering/search logic unchanged.
|
||||
*/
|
||||
initialCatalog?: VideoCatalogTemplate[];
|
||||
}
|
||||
|
||||
export function VideoTemplatesPageContent({
|
||||
initialCatalog,
|
||||
}: VideoTemplatesPageContentProps = {}) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const categoryParam = searchParams.get("category");
|
||||
@@ -51,9 +62,15 @@ export function VideoTemplatesPageContent() {
|
||||
}
|
||||
}, [categoryParam]);
|
||||
|
||||
// Use admin-sourced templates when available, fall back to the demo catalog
|
||||
const catalog =
|
||||
initialCatalog && initialCatalog.length > 0
|
||||
? initialCatalog
|
||||
: VIDEO_TEMPLATES_CATALOG;
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
filterVideoCatalog(VIDEO_TEMPLATES_CATALOG, {
|
||||
filterVideoCatalog(catalog, {
|
||||
search: debouncedSearch,
|
||||
sidebarCategory,
|
||||
aspectRatio,
|
||||
@@ -63,7 +80,8 @@ export function VideoTemplatesPageContent() {
|
||||
colorChange: false,
|
||||
scriptToVideo: false,
|
||||
}),
|
||||
[debouncedSearch, sidebarCategory, aspectRatio, premiumOnly]
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[catalog, debouncedSearch, sidebarCategory, aspectRatio, premiumOnly]
|
||||
);
|
||||
|
||||
const sections = useMemo(
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Inline SVG brand mark for FlatRender.
|
||||
*
|
||||
* Icon meaning:
|
||||
* • Blue rounded square = the platform
|
||||
* • White play triangle = video / rendering
|
||||
* • Three stacked bars = flat-design layers / composition
|
||||
*
|
||||
* Rendered inline so it works without a network request and
|
||||
* inherits the correct colour in both light and dark contexts.
|
||||
*/
|
||||
|
||||
interface LogoMarkProps {
|
||||
/** Pixel size of the square icon (default 36) */
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LogoMark({ size = 36, className }: LogoMarkProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 40 40"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden
|
||||
className={className}
|
||||
>
|
||||
{/* Blue rounded background */}
|
||||
<rect width="40" height="40" rx="9" fill="#2563EB" />
|
||||
|
||||
{/* Play triangle */}
|
||||
<path d="M12 12.5L12 27.5L24.5 20L12 12.5Z" fill="white" />
|
||||
|
||||
{/* Flat-design layer bars (decreasing width, right side) */}
|
||||
<rect x="27" y="13" width="7" height="2.5" rx="1.25" fill="white" fillOpacity="0.9" />
|
||||
<rect x="27" y="18.75" width="5.5" height="2.5" rx="1.25" fill="white" fillOpacity="0.75" />
|
||||
<rect x="27" y="24.5" width="4" height="2.5" rx="1.25" fill="white" fillOpacity="0.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Server-side fetch from the FlatRender Admin API.
|
||||
*
|
||||
* All functions return hardcoded fallback data when:
|
||||
* - ADMIN_API_URL is not set, or
|
||||
* - The admin service is unreachable.
|
||||
*
|
||||
* This means the Next.js app works standalone with no admin service running.
|
||||
*/
|
||||
|
||||
const BASE = process.env.ADMIN_API_URL?.replace(/\/$/, "");
|
||||
|
||||
export interface AdminCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
iconUrl?: string;
|
||||
type: "video" | "image" | "both";
|
||||
sortOrder: number;
|
||||
projectCount: number;
|
||||
}
|
||||
|
||||
export interface AdminProject {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
type: "video" | "image";
|
||||
status: string;
|
||||
categoryId?: string;
|
||||
categoryName?: string;
|
||||
coverImageUrl?: string;
|
||||
previewVideoUrl?: string;
|
||||
tags: string[];
|
||||
metaJson?: string;
|
||||
sortOrder: number;
|
||||
mediaCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AdminProjectsResponse {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
items: AdminProject[];
|
||||
}
|
||||
|
||||
// ── Fetch helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function safeFetch<T>(url: string): Promise<T | null> {
|
||||
if (!BASE) return null;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
next: { revalidate: 60 }, // cache for 60 s (ISR)
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return res.json() as Promise<T>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchCategories(
|
||||
type?: "video" | "image"
|
||||
): Promise<AdminCategory[]> {
|
||||
const qs = type ? `?type=${type}` : "";
|
||||
return (
|
||||
(await safeFetch<AdminCategory[]>(`${BASE}/api/public/categories${qs}`)) ??
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchProjects(opts?: {
|
||||
type?: "video" | "image";
|
||||
categorySlug?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<AdminProjectsResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts?.type) params.set("type", opts.type);
|
||||
if (opts?.categorySlug) params.set("categorySlug", opts.categorySlug);
|
||||
if (opts?.search) params.set("search", opts.search);
|
||||
if (opts?.page) params.set("page", String(opts.page));
|
||||
if (opts?.pageSize) params.set("pageSize", String(opts.pageSize));
|
||||
|
||||
const qs = params.size ? `?${params}` : "";
|
||||
return (
|
||||
(await safeFetch<AdminProjectsResponse>(
|
||||
`${BASE}/api/public/projects${qs}`
|
||||
)) ?? { total: 0, page: 1, pageSize: 20, items: [] }
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchProject(slug: string): Promise<AdminProject | null> {
|
||||
return safeFetch<AdminProject>(`${BASE}/api/public/projects/${slug}`);
|
||||
}
|
||||
|
||||
/** True when admin API is configured and reachable. */
|
||||
export async function isAdminApiAvailable(): Promise<boolean> {
|
||||
if (!BASE) return false;
|
||||
try {
|
||||
const res = await fetch(`${BASE}/api/public/categories`, {
|
||||
next: { revalidate: 30 },
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
|
||||
export function createClient() {
|
||||
return createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
);
|
||||
}
|
||||
@@ -430,3 +430,80 @@ export function toProjectTemplate(
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user