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:
Soroush.Asadi
2026-05-27 09:06:51 +03:30
parent 4875e468fe
commit 36e264f3e3
27 changed files with 1275 additions and 88 deletions
+5
View File
@@ -1,3 +1,8 @@
# FlatRender Admin API (optional — set to enable dynamic templates/categories)
# Run the admin-api service at D:\Projects\flatrender-admin\admin-api
# Leave empty to use hardcoded fallback data
ADMIN_API_URL=http://localhost:5000
# Image editor — background removal (https://www.remove.bg/api)
REMOVE_BG_API_KEY=
# Optional self-hosted rembg HTTP endpoint (POST raw image bytes → PNG)
+6
View File
@@ -34,3 +34,9 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# graphify analysis output (large generated files)
/graphify-out/
# local secrets
.env.local
+266
View File
@@ -0,0 +1,266 @@
# FlatRender — Claude Instructions
> **Read this entire file before touching any code.**
> This is the single source of truth for the AI assistant working on this project.
---
## 🗂 Project Layout (two repos, work together)
```
D:\Projects\flatrender\ ← Next.js marketing + studio app (THIS REPO)
D:\Projects\flatrender-admin\ ← Admin panel (separate repo)
admin-api\ ← .NET 10 ASP.NET Core Web API
admin-ui\ ← Vite + React + TypeScript SPA
```
Both repos must be open together — the Next.js app consumes admin-api's public endpoints.
---
## 🚀 How to Run
### Next.js app
```bash
cd D:\Projects\flatrender
npm run dev # → http://localhost:3000
npm run render-worker # optional — video render worker on :3355
```
### Admin API (.NET 10)
```bash
cd D:\Projects\flatrender-admin\admin-api
# First time: copy appsettings.Development.json.example → appsettings.Development.json
# Fill in Postgres + MinIO credentials, then:
dotnet run # → http://localhost:5000
# Scalar API docs: http://localhost:5000 (root)
```
### Admin UI (React SPA)
```bash
cd D:\Projects\flatrender-admin\admin-ui
npm run dev # → http://localhost:5173
```
### First-time admin seed
```
POST http://localhost:5000/api/auth/seed
Body: { "email": "admin@example.com", "password": "YourPassword123" }
Only works when zero admin users exist.
```
---
## 🛠 Tech Stack
| Layer | Technology |
|---|---|
| Framework | Next.js 15 App Router, TypeScript |
| Styling | Tailwind CSS v3, shadcn/ui, Framer Motion |
| Canvas | React-Konva (Video Studio + Image Editor) |
| State | Zustand (`studio-store.ts`, `image-editor-store.ts`) |
| Auth + DB | Supabase (`@supabase/ssr`) |
| Payments | Stripe |
| i18n | next-intl (`fa` = default/RTL, `en` = `/en/` prefix) |
| Fonts | Vazirmatn (Persian RTL), Plus Jakarta Sans + Inter (English) |
| Video (browser) | ffmpeg.wasm in Web Worker |
| Video (server) | nexrender + After Effects |
| Admin backend | .NET 10 ASP.NET Core, EF Core 9 + Npgsql (Supabase Postgres) |
| Admin storage | MinIO (S3-compatible) |
| Admin auth | JWT Bearer, BCrypt |
| Admin UI | Vite + React + TypeScript + Tailwind + TanStack Query |
---
## 🌐 i18n / Locale Routing
- **Persian (`fa`)** — default, no URL prefix, RTL, Vazirmatn font
- **English (`en`)** — at `/en/` prefix, LTR, Plus Jakarta Sans + Inter
Config: `src/i18n/routing.ts`
Messages: `messages/fa.json` and `messages/en.json`
Both files must have identical keys — add to both when adding new strings.
Components use `useTranslations("namespace")` hook.
**RTL font rule**: `globals.css` has a `[dir="rtl"]` block that forces Vazirmatn on all elements — do not fight it with utility classes.
---
## 🔑 Environment Variables
Copy `.env.example``.env.local` and fill in:
```env
# Admin API (optional — hardcoded fallback when not set)
ADMIN_API_URL=http://localhost:5000
# Supabase — REQUIRED for auth/dashboard/studio
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
# Stripe — required for payments
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
STRIPE_PRICE_PRO_MONTHLY=
STRIPE_PRICE_PRO_ANNUAL=
STRIPE_PRICE_BUSINESS_MONTHLY=
STRIPE_PRICE_BUSINESS_ANNUAL=
# Image background removal
REMOVE_BG_API_KEY=
# Video rendering (RENDER_MOCK=true → skip real rendering in dev)
RENDER_MOCK=true
RENDER_WORKER_URL=http://localhost:3355
NEXRENDER_BINARY=
NEXRENDER_TEMPLATE_SRC=
```
---
## 📁 Key File Map
```
src/
├── app/[locale]/
│ ├── page.tsx ← Homepage (async, fetches admin projects)
│ ├── video-maker/page.tsx ← Video Maker landing
│ ├── image-maker/page.tsx ← Image Maker landing
│ ├── templates/page.tsx ← Templates browser (async, fetches admin projects)
│ ├── pricing/page.tsx ← Pricing page
│ ├── auth/page.tsx ← Sign in / Sign up (Supabase)
│ ├── dashboard/
│ │ ├── layout.tsx ← Auth guard, DashboardShell
│ │ ├── page.tsx ← Projects grid
│ │ └── settings/page.tsx ← Settings (Profile, Security, Billing, Notifications)
│ └── studio/
│ ├── video/new/page.tsx ← New project onboarding
│ ├── video/[projectId]/page.tsx ← Video Creation Studio
│ ├── image/[projectId]/page.tsx ← Image Editor
│ └── trimmer/page.tsx ← Video Trimmer
├── components/
│ ├── layout/
│ │ ├── Navbar.tsx ← Sticky nav, dropdowns, mobile sheet
│ │ ├── Footer.tsx ← 4-column dark footer
│ │ └── SiteChrome.tsx ← Wraps pages in Navbar+Footer (skips dashboard/studio)
│ ├── ui/
│ │ └── LogoMark.tsx ← Inline SVG brand icon (used everywhere)
│ ├── sections/ ← Landing page sections
│ │ ├── TemplateGallery.tsx ← Accepts adminItems prop from server
│ │ └── template-gallery-data.ts ← Hardcoded fallback + TemplateItem type
│ ├── dashboard/
│ │ └── settings/ ← Settings sub-components
│ │ ├── SettingsProfile.tsx
│ │ ├── SettingsSecurity.tsx
│ │ ├── SettingsBilling.tsx
│ │ └── SettingsNotifications.tsx
│ ├── studio/ ← Video Studio (Konva canvas)
│ ├── image-editor/ ← Image Editor (Konva canvas)
│ └── templates/ ← Templates page components
├── lib/
│ ├── admin-api.ts ← fetchCategories, fetchProjects, fetchProject
│ ├── supabase/
│ │ ├── server.ts ← Server-side Supabase client
│ │ ├── client.ts ← Browser-side Supabase client
│ │ └── middleware.ts ← Session refresh
│ ├── studio-store.ts ← Video Studio Zustand store
│ ├── image-editor-store.ts ← Image Editor Zustand store
│ ├── video-templates-catalog.ts ← Hardcoded + admin-to-catalog mapper
│ ├── plans.ts ← PlanId type, Stripe price helpers
│ └── profiles.ts ← getUserProfile (reads Supabase profiles table)
├── i18n/
│ ├── routing.ts ← locales: ["fa","en"], defaultLocale: "fa"
│ └── request.ts ← getRequestConfig (loads messages JSON)
└── middleware.ts ← next-intl + Supabase session
messages/
├── fa.json ← Persian translations (default locale)
└── en.json ← English translations
public/
└── favicon.svg ← SVG favicon (blue rounded square + play icon)
supabase/migrations/
├── 001_profiles.sql
├── 002_render_jobs.sql
└── 003_projects.sql
server/
├── render-worker.ts ← HTTP server port 3355
├── render-job-processor.ts
└── nexrender-job-builder.ts
```
---
## 🔗 Admin API Integration
The Next.js app fetches from the admin API server-side with ISR (60-second revalidation).
**`src/lib/admin-api.ts`** exports:
- `fetchCategories(type?)``AdminCategory[]`
- `fetchProjects(opts?)``AdminProjectsResponse`
- `fetchProject(slug)``AdminProject | null`
- `isAdminApiAvailable()``boolean`
**Fallback behaviour**: If `ADMIN_API_URL` is not set or the service is unreachable, all functions return empty arrays / null — the app works standalone with hardcoded data.
**Wired pages**:
- `app/[locale]/page.tsx` → fetches 8 projects → passes `adminItems` to `<TemplateGallery>`
- `app/[locale]/templates/page.tsx` → fetches 100 video projects → converts to `VideoCatalogTemplate[]` → passes `initialCatalog` to `<VideoTemplatesPageContent>`
---
## 🗄 Database (Supabase)
Run migrations in order in Supabase SQL Editor:
1. `supabase/migrations/001_profiles.sql` — profiles (plan, billing_period, stripe IDs)
2. `supabase/migrations/002_render_jobs.sql` — render_jobs
3. `supabase/migrations/003_projects.sql` — projects + scene_data
---
## ✅ What Is Complete
- All marketing pages (Home, Video Maker, Image Maker, Pricing, Templates)
- Full Video Creation Studio (Konva canvas, timeline, layers, transitions, undo/redo)
- Full Image Editor (Konva canvas, filters, crop, adjustments, layers)
- Video Trimmer (ffmpeg.wasm, frame strip, export)
- Auth (Supabase, email/password, Google OAuth)
- Dashboard (project grid, plan badge, settings page)
- Stripe checkout + webhook for plan upgrades
- Admin API backend (full CRUD for categories, projects, media + public endpoints)
- Admin UI (dashboard, projects, categories, media library, settings)
- i18n for all public pages (fa + en)
- Logo brand mark (inline SVG `LogoMark` component)
- RTL font (Vazirmatn forced via `[dir="rtl"]` CSS)
---
## ⏳ What Still Needs Setup (credentials required)
| Item | Action needed |
|---|---|
| Supabase | Create project → get URL + anon key + service key → add to `.env.local` → run 3 SQL migrations |
| Stripe | Create products with 4 prices → add price IDs to `.env.local` |
| Admin API DB | Fill `appsettings.Development.json` with real Postgres connection string |
| MinIO | Run MinIO locally or use S3 → update admin `appsettings.Development.json` |
| Real template assets | Upload via admin panel → auto-appears on website |
| Video rendering | Set `RENDER_MOCK=false` + `NEXRENDER_BINARY` path |
---
## 📝 Coding Conventions
- **Server components** for data fetching; **`"use client"`** for interactivity
- All new translation strings go in **both** `messages/fa.json` AND `messages/en.json`
- Never hardcode English strings in components — use `useTranslations()`
- Logo: always use `<LogoMark size={36} />` — never the old `<Sparkles>` icon
- Admin API calls in page.tsx only (server-side), never in client components
- TypeScript strict — run `npx tsc --noEmit` before committing
- Tailwind only — no inline styles except Framer Motion animations
+57 -8
View File
@@ -185,11 +185,40 @@
- [x] `supabase/migrations/001_profiles.sql` — profiles table, RLS
- [x] `supabase/migrations/002_render_jobs.sql` — render_jobs table, RLS
- [x] `supabase/migrations/003_projects.sql` — projects table, RLS, updated_at trigger
- [x] `.env.example` — all required env vars documented
- [x] `.env.example` — all required env vars documented (including `ADMIN_API_URL`)
- [x] `next.config.mjs` — webpack globalObject fix + COOP/COEP headers (required for ffmpeg.wasm)
- [x] `.cursorrules` — full project rules for Cursor AI
- [x] `tailwind.config.ts` — custom colors, font families
- [x] `tailwind.config.ts` — custom colors, font families (heading, body, vazirmatn)
- [x] `components.json` — shadcn/ui config
- [x] `CLAUDE.md` — Claude Code instructions (auto-read on session start)
- [x] `public/favicon.svg` — brand favicon (SVG, blue rounded square + play icon)
### i18n
- [x] `messages/fa.json` + `messages/en.json` — full translations for all public pages
- [x] Namespaces: hero, nav, products, templates, pricing, testimonials, faq, footer, metadata, videoMaker, imageMaker
- [x] `globals.css``[dir="rtl"]` block forces Vazirmatn on all elements in Persian locale
- [x] `src/i18n/routing.ts``fa` default (no prefix), `en` at `/en/`
### Brand / Logo
- [x] `src/components/ui/LogoMark.tsx` — inline SVG brand mark (play triangle + 3 layer bars in blue square)
- [x] Navbar, Footer, DashboardSidebar — all use `<LogoMark>` (removed old `<Sparkles>` icon)
- [x] `app/opengraph-image.tsx` — proper FlatRender OG image (1200×630, headline + feature pills)
### Admin Panel integration
- [x] `src/lib/admin-api.ts``fetchCategories`, `fetchProjects`, `fetchProject`, `isAdminApiAvailable`
- [x] `app/[locale]/page.tsx` — async, fetches 8 projects → `<TemplateGallery adminItems={...}>`
- [x] `app/[locale]/templates/page.tsx` — async, fetches 100 video projects → `initialCatalog`
- [x] `TemplateGallery.tsx` — accepts `adminItems` prop, maps `AdminProject``TemplateItem`
- [x] `VideoTemplatesPageContent.tsx` — accepts `initialCatalog` prop
- [x] `video-templates-catalog.ts``adminProjectToCatalogTemplate()` mapper added
### Dashboard Settings
- [x] `/dashboard/settings` — full settings page (Profile, Security, Billing, Notifications, Danger zone)
- [x] `src/lib/supabase/client.ts` — browser Supabase client (for client-side auth updates)
- [x] `SettingsProfile.tsx` — editable display name via `supabase.auth.updateUser`
- [x] `SettingsSecurity.tsx` — change password (re-authenticates first)
- [x] `SettingsBilling.tsx` — plan info + features + Stripe billing portal link
- [x] `SettingsNotifications.tsx` — 4 email toggle switches
---
@@ -197,20 +226,29 @@
_Nothing currently in progress._
### Landing page status (2026-05-21 polish)
- `npx tsc --noEmit` — clean (no TypeScript errors)
- Tailwind `rf.blue` / `rf.blue-light``#2563EB` / `#EFF6FF`
- Remaining pre-launch work is env/migrations/E2E tests (see Must Do backlog), not landing UI
### Status as of 2026-05-27
- `npx tsc --noEmit` — clean (zero TypeScript errors)
- All public pages fully built and i18n'd (fa + en)
- Admin panel fully built (backend + frontend) — needs real Postgres + MinIO credentials
- Admin API integrated into Next.js with ISR fallback
- Logo, favicon, OG image all done
- Dashboard settings page fully functional
- Next step: fill in `.env.local` credentials and test end-to-end
---
## 📋 Backlog (Next Tasks)
### 🔴 Must Do Before Launch
- [ ] Create `.env.local` from `.env.example` and fill in real keys ← NOT DONE YET
- [ ] Run Supabase migrations (`001``002``003`) in SQL Editor ← NOT DONE YET
- [ ] Create `.env.local` from `.env.example` and fill in Supabase + Stripe keys
- [ ] Run Supabase migrations (`001``002``003`) in SQL Editor
- [ ] Set up admin API: copy `appsettings.Development.json.example``appsettings.Development.json`, fill Postgres + MinIO
- [ ] Seed first admin: `POST /api/auth/seed` with email + password
- [ ] Upload real template categories + projects via admin panel (auto-appears on website)
- [ ] Add real logo image/video assets (currently using picsum + Mixkit placeholders)
- [ ] Test full auth flow (sign up → dashboard → create project → open studio)
- [ ] Test ffmpeg.wasm trimmer end-to-end in browser
- [ ] Build settings page "Delete account" confirmation flow
### 🟡 UI Polish (Cursor screenshot-driven)
- [x] Navbar: Video/Image Maker + Learn dropdowns (Renderforest-style, no mega menu)
@@ -356,3 +394,14 @@ supabase/
| 2026-05-21 | Discovered `ProductsMegaMenu` already fully built — moved from backlog to done |
| 2026-05-21 | Added `PricingCompareTable` with 5 feature sections matching Renderforest /subscription layout |
| 2026-05-21 | Scene thumbnails: `thumbnailUrl` on Scene, `updateSceneThumbnail`, `DraggableSceneItem` img preview |
| 2026-05-27 | Built admin panel at `D:\Projects\flatrender-admin`: .NET 10 API + React SPA. Categories/Projects/Media CRUD, JWT auth, MinIO storage, public endpoints |
| 2026-05-27 | i18n: added `videoMaker` + `imageMaker` namespaces (fa + en), wired `useTranslations` in all 8 components |
| 2026-05-27 | Wired admin API into Next.js: `admin-api.ts`, homepage + templates page async with ISR, `TemplateGallery` accepts `adminItems` |
| 2026-05-27 | RTL fix: `globals.css` `[dir="rtl"]` block forces Vazirmatn on every text element |
| 2026-05-27 | `LogoMark` SVG component — replaces Sparkles in Navbar, Footer, DashboardSidebar; `public/favicon.svg` added |
| 2026-05-27 | OG image rebranded FlatRender with logo, feature pills |
| 2026-05-27 | Dashboard settings page: Profile, Security (pw change), Billing (plan + Stripe link), Notifications toggles |
| 2026-05-27 | Admin API: added `GET /api/auth/me`, `PATCH /api/auth/profile`, `POST /api/auth/change-password` |
| 2026-05-27 | Admin UI: Settings page (profile edit + password change), Settings link in sidebar |
| 2026-05-27 | `appsettings.Development.json.example` created for admin API local setup |
| 2026-05-27 | `CLAUDE.md` created — Claude Code auto-reads on session start |
+45 -21
View File
@@ -1,36 +1,60 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# FlatRender
## Getting Started
AI-powered video and image creation platform. Create professional videos and images with templates, a drag-and-drop studio, and one-click export.
First, run the development server:
## Products
- **Video Maker** — timeline editor, Konva canvas, scene browser, transitions, audio, nexrender export
- **Image Editor** — Konva canvas, filters, crop, background removal, layer system
- **Video Trimmer** — ffmpeg.wasm in-browser trim + crop + export
- **Templates** — browsable marketplace with category sidebar
## Quick Start
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
npm install
cp .env.example .env.local # fill in your credentials
npm run dev # http://localhost:3000
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
## Related Repos
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
| Repo | Path | Purpose |
|---|---|---|
| flatrender | `D:\Projects\flatrender` | This repo — Next.js app |
| flatrender-admin | `D:\Projects\flatrender-admin\admin-api` | .NET 10 Admin API |
| flatrender-admin | `D:\Projects\flatrender-admin\admin-ui` | React Admin SPA |
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Stack
## Learn More
- **Next.js 15** App Router · TypeScript · Tailwind CSS · shadcn/ui
- **Supabase** — auth, database, storage
- **Stripe** — subscription payments
- **React-Konva** — canvas editor (video + image)
- **next-intl** — Persian (default) + English i18n
- **ffmpeg.wasm** — browser-side video trimming
- **nexrender** — server-side After Effects rendering
To learn more about Next.js, take a look at the following resources:
## Environment Variables
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
See `.env.example` for the full list. Minimum required to run:
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
```env
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
```
## Deploy on Vercel
Without Supabase the app runs in mock mode (studio uses localStorage).
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
## Database Migrations
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
Run in order in Supabase SQL Editor:
1. `supabase/migrations/001_profiles.sql`
2. `supabase/migrations/002_render_jobs.sql`
3. `supabase/migrations/003_projects.sql`
## Admin Panel
The admin panel at `D:\Projects\flatrender-admin` manages templates, categories, and media. When `ADMIN_API_URL=http://localhost:5000` is set, the Next.js app fetches live data from it. Without it, hardcoded fallback data is used.
See `CLAUDE.md` for full development guide.
+7
View File
@@ -0,0 +1,7 @@
<svg width="32" height="32" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" rx="9" fill="#2563EB"/>
<path d="M12 12.5L12 27.5L24.5 20L12 12.5Z" fill="white"/>
<rect x="27" y="13" width="7" height="2.5" rx="1.25" fill="white" fill-opacity="0.9"/>
<rect x="27" y="18.75" width="5.5" height="2.5" rx="1.25" fill="white" fill-opacity="0.75"/>
<rect x="27" y="24.5" width="4" height="2.5" rx="1.25" fill="white" fill-opacity="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 494 B

+52 -9
View File
@@ -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>
);
+1
View File
@@ -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 -2
View File
@@ -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 />
+17 -2
View File
@@ -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>
);
}
+22
View File
@@ -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
View File
@@ -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 &amp; 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 &amp; 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>
);
}
+2 -4
View File
@@ -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 -4
View File
@@ -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>
+43 -5
View File
@@ -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(
+42
View File
@@ -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>
);
}
+115
View File
@@ -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;
}
}
+8
View File
@@ -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!
);
}
+77
View File
@@ -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,
};
}