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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user