From 36e264f3e33eee1886b1594030c2fc4d5433ab12 Mon Sep 17 00:00:00 2001 From: "Soroush.Asadi" Date: Wed, 27 May 2026 09:06:51 +0330 Subject: [PATCH] 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 --- .env.example | 5 + .gitignore | 6 + CLAUDE.md | 266 ++++++++++++++++++ PROJECT_MEMORY.md | 65 ++++- README.md | 66 +++-- public/favicon.svg | 7 + src/app/[locale]/dashboard/settings/page.tsx | 61 +++- src/app/[locale]/layout.tsx | 1 + src/app/[locale]/page.tsx | 10 +- src/app/[locale]/templates/page.tsx | 19 +- src/app/globals.css | 22 ++ src/app/opengraph-image.tsx | 93 ++++-- src/components/dashboard/DashboardSidebar.tsx | 6 +- .../dashboard/settings/SettingsBilling.tsx | 90 ++++++ .../settings/SettingsNotifications.tsx | 99 +++++++ .../dashboard/settings/SettingsProfile.tsx | 88 ++++++ .../dashboard/settings/SettingsSecurity.tsx | 110 ++++++++ src/components/layout/Footer.tsx | 6 +- src/components/layout/Navbar.tsx | 7 +- src/components/sections/TemplateGallery.tsx | 48 +++- .../sections/template-gallery-data.ts | 11 +- .../templates/TemplatesPageContent.tsx | 11 +- .../video/VideoTemplatesPageContent.tsx | 24 +- src/components/ui/LogoMark.tsx | 42 +++ src/lib/admin-api.ts | 115 ++++++++ src/lib/supabase/client.ts | 8 + src/lib/video-templates-catalog.ts | 77 +++++ 27 files changed, 1275 insertions(+), 88 deletions(-) create mode 100644 CLAUDE.md create mode 100644 public/favicon.svg create mode 100644 src/components/dashboard/settings/SettingsBilling.tsx create mode 100644 src/components/dashboard/settings/SettingsNotifications.tsx create mode 100644 src/components/dashboard/settings/SettingsProfile.tsx create mode 100644 src/components/dashboard/settings/SettingsSecurity.tsx create mode 100644 src/components/ui/LogoMark.tsx create mode 100644 src/lib/admin-api.ts create mode 100644 src/lib/supabase/client.ts diff --git a/.env.example b/.env.example index 1eed665..31e486d 100644 --- a/.env.example +++ b/.env.example @@ -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) diff --git a/.gitignore b/.gitignore index fd3dbb5..6542c93 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0bae44a --- /dev/null +++ b/CLAUDE.md @@ -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 `` +- `app/[locale]/templates/page.tsx` β†’ fetches 100 video projects β†’ converts to `VideoCatalogTemplate[]` β†’ passes `initialCatalog` to `` + +--- + +## πŸ—„ 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 `` β€” never the old `` 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 diff --git a/PROJECT_MEMORY.md b/PROJECT_MEMORY.md index 45ccec2..6e1856d 100644 --- a/PROJECT_MEMORY.md +++ b/PROJECT_MEMORY.md @@ -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 `` (removed old `` 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 β†’ `` +- [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 | diff --git a/README.md b/README.md index e215bc4..36f0333 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..fe51466 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/app/[locale]/dashboard/settings/page.tsx b/src/app/[locale]/dashboard/settings/page.tsx index 977e6e8..a852e1a 100644 --- a/src/app/[locale]/dashboard/settings/page.tsx +++ b/src/app/[locale]/dashboard/settings/page.tsx @@ -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 (
+ {/* Page header */}
-

- Settings -

-
-
-

- Account and workspace settings will be available here soon. +

Settings

+

+ Manage your account, security, and notification preferences.

+ + + {/* Content */} +
+
+ + + + + + {/* Danger zone */} +
+

Danger zone

+

+ Permanently delete your account and all your projects. This cannot be undone. +

+ +
+
); diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 871170f..e92e40b 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -96,6 +96,7 @@ export default async function LocaleLayout({ className={fontVars} > + - + diff --git a/src/app/[locale]/templates/page.tsx b/src/app/[locale]/templates/page.tsx index 89337f7..208f268 100644 --- a/src/app/[locale]/templates/page.tsx +++ b/src/app/[locale]/templates/page.tsx @@ -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 (
- +
); } diff --git a/src/app/globals.css b/src/app/globals.css index 14b82fb..8225925 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; diff --git a/src/app/opengraph-image.tsx b/src/app/opengraph-image.tsx index 1eeb02e..07f4047 100644 --- a/src/app/opengraph-image.tsx +++ b/src/app/opengraph-image.tsx @@ -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", }} > -
- CreatorStudio + {/* Logo mark row */} +
+ {/* Icon */} +
+ {/* Play triangle */} +
+
+ + FlatRender +
+ + {/* Main headline */}
- Create pro videos & images with AI + Create pro videos & images with AI
+ + {/* Subtitle */}
Templates, editors, and one-click export for every channel
+ + {/* Bottom pill tags */} +
+ {["Video Maker", "Image Maker", "AI Templates", "One-click Export"].map( + (tag) => ( +
+ {tag} +
+ ) + )} +
), { ...size } diff --git a/src/components/dashboard/DashboardSidebar.tsx b/src/components/dashboard/DashboardSidebar.tsx index c8df569..addb099 100644 --- a/src/components/dashboard/DashboardSidebar.tsx +++ b/src/components/dashboard/DashboardSidebar.tsx @@ -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" > - - - + FlatRender diff --git a/src/components/dashboard/settings/SettingsBilling.tsx b/src/components/dashboard/settings/SettingsBilling.tsx new file mode 100644 index 0000000..36551ef --- /dev/null +++ b/src/components/dashboard/settings/SettingsBilling.tsx @@ -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 = { + free: "Free", + pro: "Pro", + business: "Business", +}; + +const PLAN_COLORS: Record = { + 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 = { + 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 ( +
+

Billing & Plan

+

Manage your subscription and payment method.

+ + {/* Current plan card */} +
+
+
+
+ +
+
+

Current plan

+
+

{PLAN_LABELS[plan]}

+ + {isPaid ? "Active" : "Free tier"} + +
+
+
+ {isPaid ? ( + + + Manage billing + + + ) : ( + + + Upgrade + + )} +
+ + {/* Features list */} +
    + {PLAN_FEATURES[plan].map((f) => ( +
  • + + {f} +
  • + ))} +
+
+ + {!isPaid && ( +

+ Upgrade to unlock unlimited projects, 4K export, and premium templates. +

+ )} +
+ ); +} diff --git a/src/components/dashboard/settings/SettingsNotifications.tsx b/src/components/dashboard/settings/SettingsNotifications.tsx new file mode 100644 index 0000000..27427d2 --- /dev/null +++ b/src/components/dashboard/settings/SettingsNotifications.tsx @@ -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>( + 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 ( +
+

Notifications

+

Choose which emails you receive from FlatRender.

+ +
+ {TOGGLES.map((item) => ( +
+
+

{item.label}

+

{item.description}

+
+ +
+ ))} +
+ +
+ + {saved && Saved!} +
+
+ ); +} diff --git a/src/components/dashboard/settings/SettingsProfile.tsx b/src/components/dashboard/settings/SettingsProfile.tsx new file mode 100644 index 0000000..6f3c4c4 --- /dev/null +++ b/src/components/dashboard/settings/SettingsProfile.tsx @@ -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 ( +
+

Profile

+

Your public name and account email.

+ +
+
+ {initials} +
+
+

{displayName ?? email.split("@")[0]}

+

{email}

+
+
+ +
void handleSave(e)} className="mt-6 space-y-4"> +
+ + 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" + /> +
+ +
+ +
+ + {email} +
+

Email cannot be changed here. Contact support.

+
+ + {message && ( +

+ {message.text} +

+ )} + + +
+
+ ); +} diff --git a/src/components/dashboard/settings/SettingsSecurity.tsx b/src/components/dashboard/settings/SettingsSecurity.tsx new file mode 100644 index 0000000..1857d6a --- /dev/null +++ b/src/components/dashboard/settings/SettingsSecurity.tsx @@ -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 ( +
+ +
+ 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" + /> + +
+
+ ); + } + + return ( +
+

Security

+

Change your account password.

+ +
void handleSubmit(e)} className="mt-6 space-y-4"> + + + + + {message && ( +

+ {message.text} +

+ )} + + + +
+ ); +} diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index 5c956f8..533bc90 100644 --- a/src/components/layout/Footer.tsx +++ b/src/components/layout/Footer.tsx @@ -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" > - - - + {t("brandName")} diff --git a/src/components/layout/Navbar.tsx b/src/components/layout/Navbar.tsx index 311b378..4eea0f6 100644 --- a/src/components/layout/Navbar.tsx +++ b/src/components/layout/Navbar.tsx @@ -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")} > - - - + {t("brandName")} diff --git a/src/components/sections/TemplateGallery.tsx b/src/components/sections/TemplateGallery.tsx index 9e7c2ed..48eafcb 100644 --- a/src/components/sections/TemplateGallery.tsx +++ b/src/components/sections/TemplateGallery.tsx @@ -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([ + "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("All"); const [usingTemplateId, setUsingTemplateId] = useState(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 = { @@ -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} diff --git a/src/components/sections/template-gallery-data.ts b/src/components/sections/template-gallery-data.ts index d978f8d..ff89a2c 100644 --- a/src/components/sections/template-gallery-data.ts +++ b/src/components/sections/template-gallery-data.ts @@ -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 { diff --git a/src/components/templates/TemplatesPageContent.tsx b/src/components/templates/TemplatesPageContent.tsx index 6bb2e76..a90da09 100644 --- a/src/components/templates/TemplatesPageContent.tsx +++ b/src/components/templates/TemplatesPageContent.tsx @@ -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 ( }> - + ); } diff --git a/src/components/templates/video/VideoTemplatesPageContent.tsx b/src/components/templates/video/VideoTemplatesPageContent.tsx index 5800f6e..d3e1fb2 100644 --- a/src/components/templates/video/VideoTemplatesPageContent.tsx +++ b/src/components/templates/video/VideoTemplatesPageContent.tsx @@ -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( diff --git a/src/components/ui/LogoMark.tsx b/src/components/ui/LogoMark.tsx new file mode 100644 index 0000000..9278a61 --- /dev/null +++ b/src/components/ui/LogoMark.tsx @@ -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 ( + + {/* Blue rounded background */} + + + {/* Play triangle */} + + + {/* Flat-design layer bars (decreasing width, right side) */} + + + + + ); +} diff --git a/src/lib/admin-api.ts b/src/lib/admin-api.ts new file mode 100644 index 0000000..1229633 --- /dev/null +++ b/src/lib/admin-api.ts @@ -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(url: string): Promise { + 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; + } catch { + return null; + } +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +export async function fetchCategories( + type?: "video" | "image" +): Promise { + const qs = type ? `?type=${type}` : ""; + return ( + (await safeFetch(`${BASE}/api/public/categories${qs}`)) ?? + [] + ); +} + +export async function fetchProjects(opts?: { + type?: "video" | "image"; + categorySlug?: string; + search?: string; + page?: number; + pageSize?: number; +}): Promise { + 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( + `${BASE}/api/public/projects${qs}` + )) ?? { total: 0, page: 1, pageSize: 20, items: [] } + ); +} + +export async function fetchProject(slug: string): Promise { + return safeFetch(`${BASE}/api/public/projects/${slug}`); +} + +/** True when admin API is configured and reachable. */ +export async function isAdminApiAvailable(): Promise { + if (!BASE) return false; + try { + const res = await fetch(`${BASE}/api/public/categories`, { + next: { revalidate: 30 }, + }); + return res.ok; + } catch { + return false; + } +} diff --git a/src/lib/supabase/client.ts b/src/lib/supabase/client.ts new file mode 100644 index 0000000..9f2891b --- /dev/null +++ b/src/lib/supabase/client.ts @@ -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! + ); +} diff --git a/src/lib/video-templates-catalog.ts b/src/lib/video-templates-catalog.ts index a34783b..09519b8 100644 --- a/src/lib/video-templates-catalog.ts +++ b/src/lib/video-templates-catalog.ts @@ -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 { + 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, + }; +}