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,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
@@ -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;
|
||||
|
||||
+69
-16
@@ -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",
|
||||
}}
|
||||
>
|
||||
{/* Logo mark row */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16, marginBottom: 40 }}>
|
||||
{/* Icon */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 28,
|
||||
fontWeight: 600,
|
||||
color: "rgba(255,255,255,0.85)",
|
||||
marginBottom: 16,
|
||||
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",
|
||||
}}
|
||||
>
|
||||
CreatorStudio
|
||||
{/* 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