feat: full studio build -- light theme, canvas thumbnails, i18n (fa/en)
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
{"sessionId":"f6a22a","runId":"pre-fix","hypothesisId":"A","location":"TemplateCard.tsx:UseTemplateClick","message":"Use Template button clicked","data":{"templateId":"tpl-video-1","name":"Promo Reel 2","category":"Video","hasOnUseTemplateProp":false},"timestamp":1779381720022}
|
||||||
|
{"sessionId":"f6a22a","runId":"pre-fix","hypothesisId":"A","location":"TemplateCard.tsx:UseTemplateClick","message":"Use Template button clicked","data":{"templateId":"tpl-video-1","name":"Promo Reel 2","category":"Video","hasOnUseTemplateProp":false},"timestamp":1779381720512}
|
||||||
|
{"sessionId":"f6a22a","runId":"pre-fix","hypothesisId":"A","location":"TemplateCard.tsx:UseTemplateClick","message":"Use Template button clicked","data":{"templateId":"tpl-video-1","name":"Promo Reel 2","category":"Video","hasOnUseTemplateProp":false},"timestamp":1779381720934}
|
||||||
|
{"sessionId":"f6a22a","runId":"pre-fix","hypothesisId":"A","location":"TemplateCard.tsx:UseTemplateClick","message":"Use Template button clicked","data":{"templateId":"tpl-video-1","name":"Promo Reel 2","category":"Video","hasOnUseTemplateProp":false},"timestamp":1779381721478}
|
||||||
|
{"sessionId":"f6a22a","runId":"post-fix","hypothesisId":"A","location":"TemplateCard.tsx:UseTemplateClick","message":"Use Template button clicked","data":{"templateId":"tpl-video-0","name":"Promo Reel","category":"Video","hasOnUseTemplateHandler":true,"isUsingTemplate":false},"timestamp":1779381843823}
|
||||||
|
{"sessionId":"f6a22a","runId":"post-fix","hypothesisId":"E","location":"TemplatesPageContent.tsx:handleUseTemplate","message":"Creating project from template","data":{"templateId":"tpl-video-0","category":"Video"},"timestamp":1779381843823}
|
||||||
|
{"sessionId":"f6a22a","runId":"post-fix","hypothesisId":"A","location":"TemplateCard.tsx:UseTemplateClick","message":"Use Template button clicked","data":{"templateId":"tpl-video-1","name":"Promo Reel 2","category":"Video","hasOnUseTemplateHandler":true,"isUsingTemplate":false},"timestamp":1779381847288}
|
||||||
|
{"sessionId":"f6a22a","runId":"post-fix","hypothesisId":"E","location":"TemplatesPageContent.tsx:handleUseTemplate","message":"Creating project from template","data":{"templateId":"tpl-video-1","category":"Video"},"timestamp":1779381847289}
|
||||||
+22
-1
@@ -1,6 +1,15 @@
|
|||||||
# Project: CreatorStudio
|
# Project: FlatRender
|
||||||
# A Renderforest-style platform with Video Maker and Image Maker products.
|
# A Renderforest-style platform with Video Maker and Image Maker products.
|
||||||
|
|
||||||
|
## 🧠 Project Memory — READ THIS FIRST
|
||||||
|
# Before starting ANY task, read PROJECT_MEMORY.md at the project root.
|
||||||
|
# It contains: completed features, backlog, known bugs, architecture decisions,
|
||||||
|
# environment variable status, and the full file map.
|
||||||
|
# After completing a task, update PROJECT_MEMORY.md:
|
||||||
|
# - Move item from Backlog → Completed
|
||||||
|
# - Add any new bugs to Known Issues
|
||||||
|
# - Add a row to the Session Log with today's date and what changed
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
- Next.js 14 App Router (never use pages/ directory)
|
- Next.js 14 App Router (never use pages/ directory)
|
||||||
- TypeScript (strict mode, no `any`)
|
- TypeScript (strict mode, no `any`)
|
||||||
@@ -56,3 +65,15 @@
|
|||||||
- Never add console.log to committed code
|
- Never add console.log to committed code
|
||||||
- Never create a component larger than 150 lines — split it
|
- Never create a component larger than 150 lines — split it
|
||||||
- Never skip TypeScript types on props — always define an interface
|
- Never skip TypeScript types on props — always define an interface
|
||||||
|
|
||||||
|
## Studio Modules
|
||||||
|
- Canvas editor uses React-Konva (never plain HTML5 canvas API)
|
||||||
|
- All canvas state lives in Zustand studio-store.ts
|
||||||
|
- ffmpeg.wasm runs in a Web Worker only (never on main thread)
|
||||||
|
- Studio pages are "use client" — they are 100% client components
|
||||||
|
- Timeline px-per-second default: 60 (1 second = 60px wide)
|
||||||
|
- Konva Stage base size: 1280x720 (scaled to container)
|
||||||
|
- Image editor and Video Studio share the same Layer type interface
|
||||||
|
- Server render API uses nexrender for After Effects pipeline
|
||||||
|
- Never block UI during ffmpeg processing — always show progress
|
||||||
|
- All studio panels are dark theme (bg-gray-900, text-white)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Image editor — background removal (https://www.remove.bg/api)
|
||||||
|
REMOVE_BG_API_KEY=
|
||||||
|
# Optional self-hosted rembg HTTP endpoint (POST raw image bytes → PNG)
|
||||||
|
REMBG_SERVICE_URL=
|
||||||
|
|
||||||
|
# Video render pipeline
|
||||||
|
RENDER_WORKER_URL=http://localhost:3355
|
||||||
|
RENDER_WORKER_SECRET=
|
||||||
|
RENDER_WORKER_PORT=3355
|
||||||
|
RENDER_MOCK=true
|
||||||
|
NEXRENDER_TEMPLATE_SRC=file:///path/to/template.aep
|
||||||
|
NEXRENDER_COMPOSITION=CreatorStudio_Main
|
||||||
|
NEXRENDER_BINARY=
|
||||||
|
NEXRENDER_SERVER_URL=
|
||||||
|
NEXRENDER_WORKPATH=.nexrender
|
||||||
|
|
||||||
|
# Site URL for SEO metadata, OAuth, and Stripe redirects
|
||||||
|
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Supabase — required for auth, dashboard, and saving projects (templates → studio)
|
||||||
|
# https://supabase.com/dashboard/project/_/settings/api
|
||||||
|
# Without these, `npm run dev` still opens the studio via a local mock project ID (dev only).
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
|
||||||
|
|
||||||
|
# Stripe — https://dashboard.stripe.com/apikeys
|
||||||
|
STRIPE_SECRET_KEY=sk_test_...
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||||
|
|
||||||
|
# Stripe Price IDs (Products → each price → copy ID)
|
||||||
|
STRIPE_PRICE_PRO_MONTHLY=price_...
|
||||||
|
STRIPE_PRICE_PRO_ANNUAL=price_...
|
||||||
|
STRIPE_PRICE_BUSINESS_MONTHLY=price_...
|
||||||
|
STRIPE_PRICE_BUSINESS_ANNUAL=price_...
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
# FlatRender — Project Memory
|
||||||
|
> **Rule:** Read this file before starting any task. Update it after every completed feature, bug fix, or architectural decision.
|
||||||
|
> Both Cursor AI and Claude Code use this as the project brain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📌 Project Identity
|
||||||
|
|
||||||
|
| Key | Value |
|
||||||
|
|---|---|
|
||||||
|
| Project folder | `D:\Projects\flatrender` |
|
||||||
|
| Brand name | **FlatRender** (package: `flatrender`) — ✅ All UI updated to FlatRender |
|
||||||
|
| Products | Video Maker · Image Maker |
|
||||||
|
| Stack | Next.js 14 App Router · TypeScript · Tailwind CSS · shadcn/ui · Framer Motion |
|
||||||
|
| Canvas | React-Konva (Konva.js) — both Video Studio and Image Editor |
|
||||||
|
| State | Zustand (`studio-store.ts`, `image-editor-store.ts`) |
|
||||||
|
| Auth + DB | Supabase (`@supabase/ssr`) |
|
||||||
|
| Payments | Stripe |
|
||||||
|
| Video (browser) | ffmpeg.wasm in Web Worker (`src/workers/ffmpeg-trim.worker.ts`) |
|
||||||
|
| Video (server) | nexrender + Adobe After Effects (`server/render-worker.ts`) |
|
||||||
|
| Dev server | `npm run dev` → http://localhost:3000 |
|
||||||
|
| Render worker | `npm run render-worker` → http://localhost:3355 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Completed Features
|
||||||
|
|
||||||
|
### Landing Page (`/`)
|
||||||
|
- [x] `Hero` — gradient on “AI” (`from-blue-600 via-violet-500 to-blue-500`), dual CTA, preview cards, blobs
|
||||||
|
- [x] `HeroPreviewCards` — Mixkit MP4 loops, play overlay fades on hover, Framer Motion stagger
|
||||||
|
- [x] `ProductsShowcase` — Video Maker + Image Maker cards with glassmorphism style
|
||||||
|
- [x] `TemplateGallery` — filter tabs, 8-card grid, `scroll-mt-20` for sticky nav anchor
|
||||||
|
- [x] `TemplateCard` — hover Mixkit video via `previewVideoUrl`, bottom "Use Template" CTA, `AnimatePresence` fade
|
||||||
|
- [x] `template-gallery-data.ts` — `previewVideoUrl` on video/social template entries
|
||||||
|
- [x] `HowItWorks` — 3-step process, alternating layout, scroll-triggered animations
|
||||||
|
- [x] `Pricing` — monthly/annual toggle, green “Save 20%” yearly badge, 3 tiers, Stripe checkout wired
|
||||||
|
- [x] `PricingCompareTable` — full feature comparison table, 5 sections, synced billing toggle, Pro column highlight
|
||||||
|
- [x] `Testimonials` — 6-card grid
|
||||||
|
- [x] `FAQ` — accordion, 2-column layout
|
||||||
|
- [x] `Navbar` — FlatRender logo, Video/Image Maker + Learn dropdowns (shadcn), Pricing link, Sign In / Try for Free; mobile sheet (`navbar-menu-data.ts`)
|
||||||
|
- [x] `Footer` — 4-column, dark background
|
||||||
|
|
||||||
|
### Product Pages
|
||||||
|
- [x] `/video-maker` — Hero, Features, UseCases, TemplateCarousel, CTA
|
||||||
|
- [x] `/image-maker` — Hero, BeforeAfter, Gallery, Features, UseCases, CTA
|
||||||
|
|
||||||
|
### Templates Page (`/templates`)
|
||||||
|
- [x] Renderforest layout: 260px category sidebar + carousel rows (`VideoTemplatesCategorySidebar`, `VideoTemplatesCarouselRow`)
|
||||||
|
- [x] Toolbar: search, Premium Only (Switch), All Sizes select (16:9 / 9:16 / 1:1 / 4:5), Sort by (local state)
|
||||||
|
- [x] Sidebar filters panel (collapsed): Premium + size; `?category=` from navbar
|
||||||
|
- [x] `VideoTemplatesPageContent` — client-side filtering via `video-templates-catalog.ts`
|
||||||
|
- [x] Template detail `/templates/[id]` — `TemplateDetailContent` (preview, styles, Create Now, examples row); `generateStaticParams` from catalog
|
||||||
|
|
||||||
|
### Auth (`/auth`)
|
||||||
|
- [x] Sign In / Sign Up tabs
|
||||||
|
- [x] Email + password (react-hook-form + zod)
|
||||||
|
- [x] Google OAuth button
|
||||||
|
- [x] Supabase auth integration
|
||||||
|
- [x] OAuth callback route (`/auth/callback`)
|
||||||
|
- [x] Sign-out route (`/auth/sign-out`)
|
||||||
|
- [x] `SupabaseSetupNotice` — shown when env vars missing (dev-friendly)
|
||||||
|
|
||||||
|
### Dashboard (`/dashboard`)
|
||||||
|
- [x] `DashboardShell` — layout wrapper
|
||||||
|
- [x] `DashboardSidebar` — logo, nav links, user avatar + plan badge
|
||||||
|
- [x] `DashboardTopBar` — search + "New Project" dropdown
|
||||||
|
- [x] `NewProjectMenu` — Video / Image / Trimmer options
|
||||||
|
- [x] `DashboardProjectsSection` — projects grid from Supabase; `isLoading` shows 6-card skeleton grid
|
||||||
|
- [x] `DashboardProjectsContent` — async Supabase fetch (Suspense on `/dashboard`)
|
||||||
|
- [x] `SkeletonProjectCard` — pulse placeholders matching `ProjectCard` layout
|
||||||
|
- [x] `DashboardPlanBadge` — async plan fetch; sidebar `Suspense` + `DashboardPlanBadgeSkeleton`
|
||||||
|
- [x] `DashboardSidebarNav` — client nav (pathname-aware)
|
||||||
|
- [x] `DashboardEmptyState` — illustration + CTA
|
||||||
|
- [x] `ProjectCard` — thumbnail, type badge, status, 3-dot menu
|
||||||
|
- [x] `/dashboard/settings` — settings page
|
||||||
|
|
||||||
|
### Video Creation Studio (`/studio/video/[projectId]`)
|
||||||
|
- [x] `VideoStudioLayout` — icon dock (56px) + fixed 220px tool panel + full-width canvas/timeline (no right `PropertiesPanel`); `StudioMobileGate` below 768px; `useStudioProjectPersistence` (3s debounced save; dev 404 → `localStorage` `flatrender-project-{id}`)
|
||||||
|
- [x] `StudioSidebarDock` — Audio / TTS / Colors / Transitions / Font / Watermark + Guide + Keyboard (toasts); blue active bar; scenes via timeline strip only
|
||||||
|
- [x] `WatermarkSidebarContent` — upload placeholder, 3×3 position grid, opacity slider
|
||||||
|
- [x] Sidebar panels — `AudioSidebarContent`, `ColorsSidebarContent`, `TransitionsSidebarContent` (Random / No Transition tiles, apply all scenes), `FontSidebarContent`, `WatermarkSidebarContent`
|
||||||
|
- [x] `scene-browser-data.ts`, `SceneBrowserCard`, shadcn `Tabs` for media filter
|
||||||
|
- [x] `/studio/video/new` — Renderforest-style onboarding (Select Scenes / AI / presets) before editor
|
||||||
|
- [x] `VideoProjectNewContent`, `TEMPLATE_GALLERY_ITEMS` (picsum thumbnails); preset click → `/templates/[id]`; catalog includes onboarding preset ids
|
||||||
|
- [x] `SceneBrowserModal` — full-screen library (categories, Video/Photo tabs, search, 28 scenes); onboarding + studio “Browse Scenes”
|
||||||
|
- [x] `StudioMobileGate` + `useIsMobile` — desktop-only gate for video/image studio (`matchMedia` max-width 767px)
|
||||||
|
- [x] `ResizableStudioPanel` — drag-to-resize left/right panels
|
||||||
|
- [x] `StudioTopBar` — breadcrumb (My Projects → name), `StudioTopBarSaveBadge` (Local / Saved ✓ / dot), centered undo/redo + toolbar, `StudioTopBarTextControls` when text layer selected, Export dropdown → `RenderModal` presets
|
||||||
|
- [x] `PropertiesPanel` — still used by image editor; not mounted in video studio layout
|
||||||
|
- [x] `dev-project-storage.ts` — dev-only localStorage fallback when Supabase returns 404
|
||||||
|
- [x] `render-presets.ts` — full / 720p preview / GIF export presets for `RenderModal`
|
||||||
|
- [x] Scenes managed via timeline `SceneThumbnailStrip` only (no left sidebar scenes panel): 120×80px blocks, rename, browse (`SceneBrowserModal`), duplicate/delete on hover
|
||||||
|
- [x] `SceneTransitionPicker` — None / Fade / Slide / Zoom popover on outgoing scene
|
||||||
|
- [x] `scene-transitions.ts` — Framer Motion `animate()` playback (300ms fade, slide-left, zoom)
|
||||||
|
- [x] `DraggableSceneItem` + `SceneItemActions` — live Konva `thumbnailUrl` previews
|
||||||
|
- [x] `AddSceneMenu` — blank / from template
|
||||||
|
- [x] `CanvasEditor` — React-Konva Stage (1280×720), scaled to container
|
||||||
|
- [x] Text layers (draggable, resizable, rotatable)
|
||||||
|
- [x] Image layers
|
||||||
|
- [x] Video clip layers
|
||||||
|
- [x] Shape layers (rect, circle, line, arrow)
|
||||||
|
- [x] Transformer (resize handles, rotation, min 8px guard)
|
||||||
|
- [x] Click empty area → deselect
|
||||||
|
- [x] Circle drag fix (center-origin correction)
|
||||||
|
- [x] `CanvasLayerNode` router → `TextLayerNode`, `ImageLayerNode`, `ShapeLayerNode`, `VideoLayerNode`
|
||||||
|
- [x] `PropertiesPanel` — context-sensitive to selected layer type
|
||||||
|
- [x] `TextLayerProperties` — font, size, bold/italic, color, align, letter-spacing, line-height, animation style
|
||||||
|
- [x] `ImageLayerProperties` — opacity, flip H/V, replace, border-radius
|
||||||
|
- [x] `ShapeLayerProperties` — fill, stroke, stroke-width, border-radius
|
||||||
|
- [x] `CommonLayerControls` — X/Y/W/H, rotation, z-order, delete
|
||||||
|
- [x] `PropertyControls` + `useLayerUpdater`
|
||||||
|
- [x] `Timeline` — 180px Renderforest layout: `TimelineControlBar`, `SceneThumbnailStrip` / `SceneThumbnailBlock`, `TimelineActionRow`; playhead on strip (`STRIP_PX_PER_SECOND` 24); zoom slider 30–120
|
||||||
|
- [x] `TimeRuler` — time markers, click to seek
|
||||||
|
- [x] `SceneTrack` + `SceneBlock` — color-coded, drag-right-edge to resize duration
|
||||||
|
- [x] `AudioTrack` — file picker, file name display
|
||||||
|
- [x] `TimelinePlayhead` — red playhead overlay on thumbnail strip during playback
|
||||||
|
- [x] Legacy `SceneBlock` / `SceneTrack` / `TimeRuler` / `AudioTrack` — unused by Timeline (kept for reference)
|
||||||
|
- [x] `StudioToolbar` — add Text / Image / Video / Shape buttons
|
||||||
|
- [x] `RenderModal` — resolution, FPS, progress bar, download link
|
||||||
|
- [x] `useCanvasKeyboard` — Delete, Ctrl+C/V/Z/Y
|
||||||
|
- [x] `useCanvasPreviewPlayback` — scene-by-scene playback with timing
|
||||||
|
- [x] `useContainerSize` — responsive canvas scaling
|
||||||
|
- [x] `studio-store.ts` — full Zustand store (scenes, layers, playback, zoom, audio, undo/redo)
|
||||||
|
- [x] `studio-history.ts` — past/future snapshot undo stack (limit 50)
|
||||||
|
- [x] `studio-timeline.ts` — duration helpers, zoom levels, scene-at-time
|
||||||
|
- [x] `studio-types.ts` — Scene, Layer, LayerType, `SceneTransition`, AddLayerInput interfaces
|
||||||
|
- [x] `studio-snapshot.ts` — Konva stage → PNG download
|
||||||
|
- [x] `studio-canvas-stage.ts` — global stage ref registry
|
||||||
|
- [x] `studio-scene-thumbnail.ts` — `toDataURL({ pixelRatio: 0.2 })` + deferred capture after layer edits
|
||||||
|
- [x] `studio-scene-data.ts` / `image-scene-data.ts` — parse + serialize `scene_data` for persistence
|
||||||
|
- [x] `ProjectSaveIndicator` — Saving… / Saved / Local save / Save failed (image editor); video studio uses `StudioTopBarSaveBadge`
|
||||||
|
- [x] `canvas-transform.ts` — node transform → layer coords
|
||||||
|
- [x] `studio-layer-props.ts` — typed prop accessors per layer type
|
||||||
|
- [x] `dev-mock-project.ts` — dev-only mock for testing without Supabase
|
||||||
|
|
||||||
|
### Video Trimmer (`/studio/trimmer`)
|
||||||
|
- [x] `TrimmerUploadZone` — drag-drop + click, accepts video/*
|
||||||
|
- [x] `TrimmerVideoPreview` — `<video>` + `react-rnd` crop overlay, aspect ratio buttons
|
||||||
|
- [x] `TrimmerStrip` — frame thumbnail strip, draggable trim handles
|
||||||
|
- [x] `TrimmerExportSection` — MP4/WebM toggle, export button, progress, download
|
||||||
|
- [x] `ffmpeg-trim.worker.ts` — full ffmpeg.wasm Web Worker (init, progress, process, complete)
|
||||||
|
- [x] `ffmpeg-worker-client.ts` — typed client to communicate with worker
|
||||||
|
- [x] `trimmer-types.ts` + `trimmer-utils.ts` — types and crop scaling math
|
||||||
|
|
||||||
|
### Image Editor (`/studio/image` and `/studio/image/[projectId]`)
|
||||||
|
- [x] `ImageEditorLayout` — full-viewport, dark theme; mobile gate; `useImageProjectPersistence` auto-save
|
||||||
|
- [x] `ImageEditorTopBar` — project name, export button
|
||||||
|
- [x] `ImageEditorToolbar` — Select, Crop, Text, Shape, Draw, AI tools
|
||||||
|
- [x] `ImageEditorCanvas` — dynamic import (SSR off), Konva stage
|
||||||
|
- [x] `ImageBaseLayer` — base image rendering with Konva filters
|
||||||
|
- [x] `ImageEditorLayerNode` — text/shape/draw layer nodes
|
||||||
|
- [x] `ImageCropOverlay` — `react-rnd` crop box on canvas (aspect lock)
|
||||||
|
- [x] `VignetteOverlay` — radial gradient vignette
|
||||||
|
- [x] `ImageCropControls` — aspect presets (Free, 1:1, 16:9, 4:3, 9:16), Apply/Cancel above canvas
|
||||||
|
- [x] `image-editor-crop.ts` — aspect math, canvas→source crop, `cropImageDataUrl`
|
||||||
|
- [x] `ImageEditorRightPanel` — tabbed: Adjust | Filters | Layers
|
||||||
|
- [x] `AdjustPanel` — brightness, contrast, saturation, hue, blur, sharpen, vignette sliders
|
||||||
|
- [x] `FiltersPanel` — 12 preset filter thumbnails
|
||||||
|
- [x] `LayersPanel` — reorder, hide, lock, delete
|
||||||
|
- [x] `AiRemoveBgModal` — calls `/api/remove-bg`, shows result
|
||||||
|
- [x] `image-editor-store.ts` — Zustand store for image editor
|
||||||
|
- [x] `image-editor-filters.ts` — Konva filter pipeline helpers
|
||||||
|
- [x] `image-editor-konva.ts` — Konva helpers
|
||||||
|
- [x] `image-editor-types.ts` — ImageLayer, ImageEditorState types
|
||||||
|
- [x] `image-editor-export.ts` — stage → PNG/JPG/WebP download
|
||||||
|
- [x] `image-editor-stage-ref.ts` — global stage ref
|
||||||
|
- [x] `image-editor-transform.ts` — transform helpers
|
||||||
|
|
||||||
|
### API Routes
|
||||||
|
- [x] `POST /api/checkout` — creates Stripe Checkout session
|
||||||
|
- [x] `POST /api/webhooks/stripe` — updates user plan on checkout.session.completed
|
||||||
|
- [x] `GET/POST /api/projects` — fetch / create user projects
|
||||||
|
- [x] `GET/PATCH /api/projects/[projectId]` — load project + auto-save `scene_data`
|
||||||
|
- [x] `POST /api/remove-bg` — calls remove.bg or rembg service
|
||||||
|
- [x] `POST /api/render` — queues render job in Supabase
|
||||||
|
- [x] `GET /api/render/[jobId]/status` — poll render job status
|
||||||
|
|
||||||
|
### Server (render worker process)
|
||||||
|
- [x] `server/render-worker.ts` — HTTP server on port 3355, `/health` + `/process`
|
||||||
|
- [x] `server/render-job-processor.ts` — fetches job from Supabase, runs nexrender, uploads result
|
||||||
|
- [x] `server/nexrender-job-builder.ts` — builds nexrender job JSON from scene data
|
||||||
|
- [x] `server/nexrender.d.ts` — type declarations for @nexrender/core
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
- [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] `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] `components.json` — shadcn/ui config
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 In Progress
|
||||||
|
|
||||||
|
_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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 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
|
||||||
|
- [ ] Test full auth flow (sign up → dashboard → create project → open studio)
|
||||||
|
- [ ] Test ffmpeg.wasm trimmer end-to-end in browser
|
||||||
|
|
||||||
|
### 🟡 UI Polish (Cursor screenshot-driven)
|
||||||
|
- [x] Navbar: Video/Image Maker + Learn dropdowns (Renderforest-style, no mega menu)
|
||||||
|
- [x] Landing polish pass: `rf-blue` / `rf-blue-light` tokens, Hero AI gradient, pricing Save 20% badge, `#templates` scroll-mt-20
|
||||||
|
- [x] Hero: animated video thumbnail preview cards (autoplay muted)
|
||||||
|
- [x] TemplateCard: video preview on hover (autoplay muted loop, AnimatePresence fade)
|
||||||
|
- [x] Studio: scene thumbnail auto-generated from Konva canvas (toDataURL)
|
||||||
|
- [x] Studio: transition picker between scenes (fade, slide, zoom)
|
||||||
|
- [x] Dashboard: skeleton loading states for project cards
|
||||||
|
- [x] Mobile: studio pages desktop gate (under 768px shows `StudioMobileGate`, not full editor)
|
||||||
|
|
||||||
|
### 🟢 Nice to Have
|
||||||
|
- [ ] Template system: pre-built `.aep` templates for nexrender
|
||||||
|
- [ ] Image editor: text curved/arc effect
|
||||||
|
- [ ] Image editor: sticker/icon library (200+ SVGs)
|
||||||
|
- [ ] Video studio: background color/gradient picker per scene
|
||||||
|
- [ ] Onboarding flow for new users (first project wizard)
|
||||||
|
- [ ] Usage limits per plan (enforced server-side)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Known Issues
|
||||||
|
|
||||||
|
| # | Issue | File | Priority |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Brand name "CreatorStudio" in UI | ✅ Fixed 2026-05-21 — all 4 files updated | — |
|
||||||
|
| 2 | `next.config.mjs` was missing COOP/COEP headers | ✅ Fixed 2026-05-21 | — |
|
||||||
|
| 3 | Scene thumbnails are placeholder gray boxes | ✅ Fixed 2026-05-21 — `thumbnailUrl` via `updateSceneThumbnail` | — |
|
||||||
|
| 4 | No loading/error state in trimmer if ffmpeg CDN is slow | `TrimmerExportSection` | 🟡 Medium |
|
||||||
|
| 5 | Image editor crop tool not fully implemented | ✅ Fixed 2026-05-21 — `ImageCropControls`, `ImageCropOverlay`, pixel crop in `applyCrop` | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Architecture Decisions
|
||||||
|
|
||||||
|
| Decision | Rationale |
|
||||||
|
|---|---|
|
||||||
|
| React-Konva for all canvas work | Consistent layer model across Video Studio and Image Editor |
|
||||||
|
| ffmpeg.wasm in Web Worker only | Never block main thread; SharedArrayBuffer requires COOP/COEP headers in `next.config.mjs` |
|
||||||
|
| Zustand for studio state (not React context) | Avoids re-render cascade on every canvas update |
|
||||||
|
| nexrender + aerender for final render | AE templates give highest quality output; `RENDER_MOCK=true` skips it in dev |
|
||||||
|
| Supabase RLS on all tables | Row-level security — users can only access their own data |
|
||||||
|
| `studio-canvas-stage.ts` global stage ref | Allows snapshot/export from any component without prop-drilling |
|
||||||
|
| Dynamic import for ImageEditorCanvas | Konva cannot run on server — `ssr: false` prevents SSR crash |
|
||||||
|
| `dev-mock-project.ts` | Dev POST mock; `dev-project` id skips load/save in studio editors |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Production-Ready Checklist
|
||||||
|
|
||||||
|
| Item | Status |
|
||||||
|
|---|---|
|
||||||
|
| `next.config.mjs` — `output: 'standalone'` (Docker) | ✅ |
|
||||||
|
| `app/sitemap.ts` — `/`, `/video-maker`, `/image-maker`, `/templates`, `/pricing` | ✅ |
|
||||||
|
| `app/robots.ts` — allow public; disallow `/dashboard`, `/studio`, `/api` | ✅ |
|
||||||
|
| Page metadata via `createPageMetadata` (public routes + studio layouts) | ✅ |
|
||||||
|
| `app/pricing/page.tsx` — dedicated pricing route for SEO/sitemap | ✅ |
|
||||||
|
| `app/error.tsx` — error boundary with reload | ✅ |
|
||||||
|
| `app/not-found.tsx` — 404 with home CTA | ✅ |
|
||||||
|
| `npx tsc --noEmit` clean | ✅ (re-run before deploy) |
|
||||||
|
| `.env.local` + Supabase migrations | ⬜ Operator setup |
|
||||||
|
| `NEXT_PUBLIC_SITE_URL` set to production domain | ⬜ Required for sitemap/OG URLs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔑 Environment Variables Checklist
|
||||||
|
|
||||||
|
| Variable | Status | Where to get it |
|
||||||
|
|---|---|---|
|
||||||
|
| `NEXT_PUBLIC_SUPABASE_URL` | ⬜ Not set | Supabase → Settings → API |
|
||||||
|
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | ⬜ Not set | Supabase → Settings → API |
|
||||||
|
| `SUPABASE_SERVICE_ROLE_KEY` | ⬜ Not set | Supabase → Settings → API |
|
||||||
|
| `STRIPE_SECRET_KEY` | ⬜ Not set | Stripe Dashboard → API Keys |
|
||||||
|
| `STRIPE_WEBHOOK_SECRET` | ⬜ Not set | Stripe → Webhooks → signing secret |
|
||||||
|
| `STRIPE_PRICE_PRO_MONTHLY` | ⬜ Not set | Stripe → Products → price ID |
|
||||||
|
| `STRIPE_PRICE_PRO_ANNUAL` | ⬜ Not set | Stripe → Products → price ID |
|
||||||
|
| `STRIPE_PRICE_BUSINESS_MONTHLY` | ⬜ Not set | Stripe → Products → price ID |
|
||||||
|
| `STRIPE_PRICE_BUSINESS_ANNUAL` | ⬜ Not set | Stripe → Products → price ID |
|
||||||
|
| `REMOVE_BG_API_KEY` | ⬜ Not set | remove.bg → API |
|
||||||
|
| `RENDER_WORKER_URL` | ✅ Default: `http://localhost:3355` | — |
|
||||||
|
| `RENDER_MOCK` | ✅ Default: `true` | Set to `false` when AE is configured |
|
||||||
|
| `NEXRENDER_TEMPLATE_SRC` | ⬜ Not set | Path to your `.aep` template file |
|
||||||
|
| `NEXRENDER_BINARY` | ⬜ Not set | Path to `aerender` executable |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Key File Map (Quick Reference)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/
|
||||||
|
│ ├── page.tsx ← Landing page
|
||||||
|
│ ├── video-maker/page.tsx ← Video Maker product page
|
||||||
|
│ ├── image-maker/page.tsx ← Image Maker product page
|
||||||
|
│ ├── templates/page.tsx ← Templates gallery
|
||||||
|
│ ├── pricing/page.tsx ← Pricing (public SEO route)
|
||||||
|
│ ├── sitemap.ts / robots.ts ← SEO + crawler rules
|
||||||
|
│ ├── error.tsx / not-found.tsx ← Global error UI
|
||||||
|
│ ├── auth/page.tsx ← Sign in / Sign up
|
||||||
|
│ ├── dashboard/page.tsx ← User dashboard
|
||||||
|
│ ├── studio/
|
||||||
|
│ │ ├── video/new/page.tsx ← New video project onboarding
|
||||||
|
│ │ ├── video/[projectId]/page.tsx ← Video Creation Studio
|
||||||
|
│ │ ├── image/[projectId]/page.tsx ← Image Editor
|
||||||
|
│ │ └── trimmer/page.tsx ← Video Trimmer/Cropper
|
||||||
|
│ └── api/ ← All API routes
|
||||||
|
├── components/
|
||||||
|
│ ├── layout/ ← Navbar, Footer, SiteChrome
|
||||||
|
│ ├── sections/ ← Landing page sections
|
||||||
|
│ ├── studio/ ← Video Studio components
|
||||||
|
│ ├── image-editor/ ← Image Editor components
|
||||||
|
│ ├── trimmer/ ← Video Trimmer components
|
||||||
|
│ ├── dashboard/ ← Dashboard components
|
||||||
|
│ └── ui/ ← shadcn/ui (do not edit)
|
||||||
|
├── lib/
|
||||||
|
│ ├── studio-store.ts ← Video Studio Zustand store
|
||||||
|
│ ├── studio-types.ts ← Scene / Layer types
|
||||||
|
│ ├── image-editor-store.ts ← Image Editor Zustand store
|
||||||
|
│ └── supabase/ ← Supabase client helpers
|
||||||
|
├── hooks/ ← useCanvasKeyboard, useContainerSize, useIsMobile, etc.
|
||||||
|
└── workers/
|
||||||
|
└── ffmpeg-trim.worker.ts ← ffmpeg.wasm Web Worker
|
||||||
|
|
||||||
|
server/
|
||||||
|
├── render-worker.ts ← HTTP server (port 3355)
|
||||||
|
├── render-job-processor.ts ← nexrender job runner
|
||||||
|
└── nexrender-job-builder.ts ← builds AE job from scene JSON
|
||||||
|
|
||||||
|
supabase/
|
||||||
|
└── migrations/ ← SQL files, run in order in Supabase SQL Editor
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📅 Session Log
|
||||||
|
|
||||||
|
| Date | What was done |
|
||||||
|
|---|---|
|
||||||
|
| 2026-05-21 | Full project scaffolded: all pages, studio modules, API routes, Supabase migrations, server render worker |
|
||||||
|
| 2026-05-21 | Fixed `next.config.mjs` — added COOP/COEP headers required for ffmpeg.wasm SharedArrayBuffer |
|
||||||
|
| 2026-05-21 | Created `PROJECT_MEMORY.md` — project brain for Cursor + Claude |
|
||||||
|
| 2026-05-21 | Fixed brand name in 4 files: `metadata.ts`, `DashboardSidebar.tsx`, `ProductsMegaMenu.tsx`, `Navbar.tsx` |
|
||||||
|
| 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 |
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.ts",
|
||||||
|
"css": "src/app/globals.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"homeTitle": "Create Pro Videos & Images with AI",
|
||||||
|
"homeDescription": "FlatRender helps creators and brands make professional videos and images with AI templates, editors, and one-click export."
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"brandName": "FlatRender",
|
||||||
|
"ariaLabel": "FlatRender home",
|
||||||
|
"videoMaker": "Video Maker",
|
||||||
|
"imageMaker": "Image Maker",
|
||||||
|
"pricing": "Pricing",
|
||||||
|
"learn": "Learn",
|
||||||
|
"signIn": "Sign In",
|
||||||
|
"tryForFree": "Try for Free",
|
||||||
|
"openMenuAriaLabel": "Open navigation menu",
|
||||||
|
"mobileMenuTitle": "Menu",
|
||||||
|
"videoMakerBrowse": "Browse Templates",
|
||||||
|
"imageMakerBrowse": "Browse Image Templates",
|
||||||
|
"videoMakerItems": {
|
||||||
|
"animation": "Animation Videos",
|
||||||
|
"intros": "Intros & Logos",
|
||||||
|
"social": "Social Media",
|
||||||
|
"slideshow": "Slideshow",
|
||||||
|
"ads": "Video Ad Templates",
|
||||||
|
"music": "Music Visualisation",
|
||||||
|
"featured": "Featured Animations"
|
||||||
|
},
|
||||||
|
"imageMakerItems": {
|
||||||
|
"social": "Social Media Graphics",
|
||||||
|
"banners": "Banners & Ads",
|
||||||
|
"presentations": "Presentations",
|
||||||
|
"posters": "Posters & Flyers",
|
||||||
|
"logos": "Logo Maker"
|
||||||
|
},
|
||||||
|
"learnItems": {
|
||||||
|
"blog": "Blog",
|
||||||
|
"tutorials": "Tutorials",
|
||||||
|
"help": "Help Center"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"langSwitcher": {
|
||||||
|
"label": "Language",
|
||||||
|
"fa": "فارسی",
|
||||||
|
"en": "English"
|
||||||
|
},
|
||||||
|
"hero": {
|
||||||
|
"badge": "Trusted by 1M+ creators worldwide",
|
||||||
|
"title": "Create stunning videos & images with <highlight>AI</highlight> in minutes",
|
||||||
|
"description": "AI-powered video maker and image maker with 1,200+ templates built by professional motion designers.",
|
||||||
|
"cta": "Try for Free",
|
||||||
|
"browse": "Browse Templates",
|
||||||
|
"previewsLabel": "Template video previews"
|
||||||
|
},
|
||||||
|
"products": {
|
||||||
|
"heading": "Everything you need to create",
|
||||||
|
"videoMakerTitle": "Video Maker",
|
||||||
|
"videoMakerDesc": "Edit scenes, add motion graphics, and export broadcast-ready videos with AI-assisted timelines and templates.",
|
||||||
|
"videoMakerLink": "Try Video Maker",
|
||||||
|
"videoMakerBadge": "Popular",
|
||||||
|
"imageMakerTitle": "Image Maker",
|
||||||
|
"imageMakerDesc": "Design social posts, ads, and brand assets with smart layouts, backgrounds, and one-click resizing.",
|
||||||
|
"imageMakerLink": "Try Image Maker",
|
||||||
|
"imageMakerBadge": "New"
|
||||||
|
},
|
||||||
|
"templates": {
|
||||||
|
"heading": "1,200+ Professional Templates",
|
||||||
|
"tabAll": "All",
|
||||||
|
"tabVideos": "Videos",
|
||||||
|
"tabImages": "Images",
|
||||||
|
"tabSocial": "Social Media",
|
||||||
|
"tabBusiness": "Business",
|
||||||
|
"browseAll": "Browse all templates",
|
||||||
|
"useTemplate": "Use Template",
|
||||||
|
"opening": "Opening…"
|
||||||
|
},
|
||||||
|
"howItWorks": {
|
||||||
|
"heading": "How it works",
|
||||||
|
"subtitle": "Three simple steps from idea to publish-ready creative.",
|
||||||
|
"step1Title": "Choose a template or start blank",
|
||||||
|
"step1Desc": "Browse 1,200+ professional templates or open a blank canvas tailored for video, image, or social formats.",
|
||||||
|
"step2Title": "Customize with our AI-powered editor",
|
||||||
|
"step2Desc": "Drag, drop, and refine every layer. AI suggests copy, colors, and layouts so you ship polished work faster.",
|
||||||
|
"step3Title": "Export and share in one click",
|
||||||
|
"step3Desc": "Download HD exports or publish directly to social channels, ads, and presentations without leaving the app."
|
||||||
|
},
|
||||||
|
"testimonials": {
|
||||||
|
"heading": "Loved by creators worldwide",
|
||||||
|
"item0Name": "Sarah Chen",
|
||||||
|
"item0Role": "Content Director",
|
||||||
|
"item0Company": "Bloom Studio",
|
||||||
|
"item0Quote": "We cut our promo turnaround from three days to a few hours. The AI editor suggestions are scary good.",
|
||||||
|
"item0Initials": "SC",
|
||||||
|
"item1Name": "Marcus Webb",
|
||||||
|
"item1Role": "Founder",
|
||||||
|
"item1Company": "Launchpad SaaS",
|
||||||
|
"item1Quote": "Pitch decks, product demos, and social clips all live in one place now. Our team actually enjoys making assets.",
|
||||||
|
"item1Initials": "MW",
|
||||||
|
"item2Name": "Elena Ruiz",
|
||||||
|
"item2Role": "Social Media Manager",
|
||||||
|
"item2Company": "North & Co.",
|
||||||
|
"item2Quote": "Template quality beats what we paid agencies for last year. Resizing for every platform is basically automatic.",
|
||||||
|
"item2Initials": "ER",
|
||||||
|
"item3Name": "James Okonkwo",
|
||||||
|
"item3Role": "YouTube Creator",
|
||||||
|
"item3Company": "240K subscribers",
|
||||||
|
"item3Quote": "I batch a week of thumbnails and shorts in one sitting. Exports are crisp and upload-ready every time.",
|
||||||
|
"item3Initials": "JO",
|
||||||
|
"item4Name": "Priya Nair",
|
||||||
|
"item4Role": "Brand Designer",
|
||||||
|
"item4Company": "Studio Kite",
|
||||||
|
"item4Quote": "The brand kit keeps colors and fonts locked so freelancers cannot drift off-brand. Huge for client work.",
|
||||||
|
"item4Initials": "PN",
|
||||||
|
"item5Name": "David Park",
|
||||||
|
"item5Role": "Marketing Lead",
|
||||||
|
"item5Company": "Harbor Retail",
|
||||||
|
"item5Quote": "We rolled FlatRender out to 12 stores for local ads. Store managers need no design background to ship campaigns.",
|
||||||
|
"item5Initials": "DP"
|
||||||
|
},
|
||||||
|
"faq": {
|
||||||
|
"heading": "Frequently asked questions",
|
||||||
|
"subtitle": "Everything you need to know about FlatRender. Can't find an answer? Contact our support team.",
|
||||||
|
"q0": "What is FlatRender?",
|
||||||
|
"a0": "FlatRender is an all-in-one creative platform for making professional videos and images. Choose from 1,200+ templates or start blank, customize with our AI-powered editor, and export in formats ready for social, ads, and presentations.",
|
||||||
|
"q1": "Is it free?",
|
||||||
|
"a1": "Yes. The Free plan includes 5 exports per month, 720p video exports, and access to basic templates—no credit card required. Upgrade to Pro or Business when you need unlimited exports, 4K quality, AI tools, and team features.",
|
||||||
|
"q2": "What formats can I export?",
|
||||||
|
"a2": "Export videos as MP4 (up to 4K on paid plans), images as PNG or JPG, and animated assets as GIF or MP4. Presets are included for Instagram, TikTok, YouTube, LinkedIn, and standard print dimensions.",
|
||||||
|
"q3": "Can I use it for commercial use?",
|
||||||
|
"a3": "Yes. All paid plans include a commercial license for client work, ads, and branded content. Free plan exports are licensed for personal and non-commercial projects unless you upgrade.",
|
||||||
|
"q4": "How does the Video Maker work?",
|
||||||
|
"a4": "Pick a video template or blank timeline, swap scenes and text, add music and motion graphics, then let AI suggest cuts and captions. Preview in real time and export a single file optimized for your target platform.",
|
||||||
|
"q5": "How does the Image Maker work?",
|
||||||
|
"a5": "Start from a template or custom canvas, edit layers like text, photos, and shapes, and use AI to generate backgrounds or resize layouts for every channel. Export high-resolution stills in one click.",
|
||||||
|
"q6": "Do I need design skills?",
|
||||||
|
"a6": "No. Templates and smart layouts handle typography, spacing, and color. The editor guides you with drag-and-drop controls and AI suggestions, so beginners and pros can both ship polished work quickly.",
|
||||||
|
"q7": "Can I cancel anytime?",
|
||||||
|
"a7": "Yes. Cancel from your account settings at any time. You keep access through the end of your billing period, and you can downgrade to Free without losing your projects."
|
||||||
|
},
|
||||||
|
"pricing": {
|
||||||
|
"heading": "Choose your FlatRender plan",
|
||||||
|
"monthly": "Monthly",
|
||||||
|
"annual": "Annual",
|
||||||
|
"saveBadge": "Save up to {percent}%",
|
||||||
|
"subscribe": "Subscribe",
|
||||||
|
"freeBannerTitle": "Free plan",
|
||||||
|
"freeBannerDesc": "Free forever, no credit card required",
|
||||||
|
"perMonth": "/ mo",
|
||||||
|
"billedAnnually": "billed annually",
|
||||||
|
"compareTitle": "Compare all plans",
|
||||||
|
"allFeatures": "All features",
|
||||||
|
"unlimited": "Unlimited",
|
||||||
|
"liteName": "Lite",
|
||||||
|
"liteDesc": "Gain access to premium features for personal use.",
|
||||||
|
"proName": "Pro",
|
||||||
|
"proDesc": "Become a pro and unlock more powerful video, design and website editing tools for commercial use.",
|
||||||
|
"businessName": "Business",
|
||||||
|
"businessDesc": "Advanced level solution for teams and businesses. Includes reseller license."
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"brandName": "FlatRender",
|
||||||
|
"description": "Create pro videos and images with AI-powered tools built for creators and brands.",
|
||||||
|
"products": "Products",
|
||||||
|
"company": "Company",
|
||||||
|
"legal": "Legal",
|
||||||
|
"rights": "© {year} FlatRender. All rights reserved.",
|
||||||
|
"madeWith": "Made with ❤️",
|
||||||
|
"videoMaker": "Video Maker",
|
||||||
|
"imageMaker": "Image Maker",
|
||||||
|
"templates": "Templates",
|
||||||
|
"pricingLink": "Pricing",
|
||||||
|
"about": "About",
|
||||||
|
"blog": "Blog",
|
||||||
|
"careers": "Careers",
|
||||||
|
"contact": "Contact",
|
||||||
|
"privacy": "Privacy Policy",
|
||||||
|
"terms": "Terms",
|
||||||
|
"cookies": "Cookie Policy",
|
||||||
|
"socialX": "X (Twitter)",
|
||||||
|
"socialInstagram": "Instagram",
|
||||||
|
"socialLinkedIn": "LinkedIn",
|
||||||
|
"socialYouTube": "YouTube"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"signIn": "Sign In",
|
||||||
|
"signUp": "Sign Up",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Password",
|
||||||
|
"forgotPassword": "Forgot password?",
|
||||||
|
"noAccount": "Don't have an account?",
|
||||||
|
"hasAccount": "Already have an account?",
|
||||||
|
"signInHere": "Sign in here",
|
||||||
|
"signUpHere": "Sign up here",
|
||||||
|
"continueWithGoogle": "Continue with Google",
|
||||||
|
"or": "or"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"myProjects": "My Projects",
|
||||||
|
"newProject": "New Project",
|
||||||
|
"settings": "Settings",
|
||||||
|
"signOut": "Sign Out",
|
||||||
|
"noProjects": "No projects yet",
|
||||||
|
"noProjectsDesc": "Create a new project to get started",
|
||||||
|
"createFirst": "Create your first project",
|
||||||
|
"video": "Video",
|
||||||
|
"image": "Image",
|
||||||
|
"openInStudio": "Open in Studio",
|
||||||
|
"download": "Download",
|
||||||
|
"rename": "Rename",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
|
"delete": "Delete",
|
||||||
|
"rendering": "Rendering",
|
||||||
|
"ready": "Ready",
|
||||||
|
"draft": "Draft",
|
||||||
|
"editedToday": "Edited today",
|
||||||
|
"editedYesterday": "Edited yesterday"
|
||||||
|
},
|
||||||
|
"studio": {
|
||||||
|
"newVideo": "New Video",
|
||||||
|
"createNewVideo": "Create new video",
|
||||||
|
"selectOption": "Select one of the options to start creating",
|
||||||
|
"selectScenes": "Select Scenes",
|
||||||
|
"selectScenesDesc": "Browse scenes and build your project from scratch",
|
||||||
|
"createWithAI": "Create with AI",
|
||||||
|
"createWithAIDesc": "Transform your ideas or script into AI-generated videos effortlessly",
|
||||||
|
"orStartWith": "OR",
|
||||||
|
"searchPresets": "Search presets...",
|
||||||
|
"useTemplate": "Use Template"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"loading": "Loading...",
|
||||||
|
"error": "Error",
|
||||||
|
"retry": "Retry",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"save": "Save",
|
||||||
|
"close": "Close",
|
||||||
|
"back": "Back",
|
||||||
|
"next": "Next",
|
||||||
|
"previous": "Previous",
|
||||||
|
"search": "Search",
|
||||||
|
"filter": "Filter",
|
||||||
|
"all": "All",
|
||||||
|
"popular": "Popular",
|
||||||
|
"new": "New"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"homeTitle": "ساخت ویدیو و تصویر حرفهای با هوش مصنوعی",
|
||||||
|
"homeDescription": "FlatRender به سازندگان و برندها کمک میکند ویدیوها و تصاویر حرفهای با قالبهای هوش مصنوعی، ویرایشگرها و خروجی یککلیکی بسازند."
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"brandName": "FlatRender",
|
||||||
|
"ariaLabel": "صفحه اصلی FlatRender",
|
||||||
|
"videoMaker": "ویدیو ساز",
|
||||||
|
"imageMaker": "تصویر ساز",
|
||||||
|
"pricing": "قیمتگذاری",
|
||||||
|
"learn": "یادگیری",
|
||||||
|
"signIn": "ورود",
|
||||||
|
"tryForFree": "رایگان شروع کنید",
|
||||||
|
"openMenuAriaLabel": "باز کردن منو",
|
||||||
|
"mobileMenuTitle": "منو",
|
||||||
|
"videoMakerBrowse": "مرور قالبها",
|
||||||
|
"imageMakerBrowse": "مرور قالبهای تصویری",
|
||||||
|
"videoMakerItems": {
|
||||||
|
"animation": "ویدیوهای انیمیشن",
|
||||||
|
"intros": "اینترو و لوگو",
|
||||||
|
"social": "رسانههای اجتماعی",
|
||||||
|
"slideshow": "اسلایدشو",
|
||||||
|
"ads": "قالبهای آگهی ویدیویی",
|
||||||
|
"music": "ویژوالایزر موسیقی",
|
||||||
|
"featured": "انیمیشنهای ویژه"
|
||||||
|
},
|
||||||
|
"imageMakerItems": {
|
||||||
|
"social": "گرافیک رسانههای اجتماعی",
|
||||||
|
"banners": "بنر و آگهی",
|
||||||
|
"presentations": "ارائهها",
|
||||||
|
"posters": "پوستر و تراکت",
|
||||||
|
"logos": "لوگوساز"
|
||||||
|
},
|
||||||
|
"learnItems": {
|
||||||
|
"blog": "وبلاگ",
|
||||||
|
"tutorials": "آموزشها",
|
||||||
|
"help": "مرکز راهنما"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"langSwitcher": {
|
||||||
|
"label": "زبان",
|
||||||
|
"fa": "فارسی",
|
||||||
|
"en": "English"
|
||||||
|
},
|
||||||
|
"hero": {
|
||||||
|
"badge": "مورد اعتماد بیش از ۱ میلیون سازنده در سراسر جهان",
|
||||||
|
"title": "ویدیوها و تصاویر خیرهکننده با <highlight>هوش مصنوعی</highlight> در چند دقیقه بسازید",
|
||||||
|
"description": "ویدیو ساز و تصویر ساز مبتنی بر هوش مصنوعی با بیش از ۱٬۲۰۰ قالب ساختهشده توسط طراحان حرفهای موشن",
|
||||||
|
"cta": "رایگان شروع کنید",
|
||||||
|
"browse": "مرور قالبها",
|
||||||
|
"previewsLabel": "پیشنمایش ویدیوهای قالب"
|
||||||
|
},
|
||||||
|
"products": {
|
||||||
|
"heading": "همه چیزی که برای ساخت محتوا نیاز دارید",
|
||||||
|
"videoMakerTitle": "ویدیو ساز",
|
||||||
|
"videoMakerDesc": "صحنهها را ویرایش کنید، موشن گرافیک اضافه کنید و ویدیوهای آماده پخش را با تایملاینها و قالبهای هوشمند خروجی بگیرید.",
|
||||||
|
"videoMakerLink": "ویدیو ساز را امتحان کنید",
|
||||||
|
"videoMakerBadge": "محبوب",
|
||||||
|
"imageMakerTitle": "تصویر ساز",
|
||||||
|
"imageMakerDesc": "پستهای اجتماعی، آگهیها و داراییهای برند را با چیدمانهای هوشمند، پسزمینهها و تغییر اندازه یککلیکی طراحی کنید.",
|
||||||
|
"imageMakerLink": "تصویر ساز را امتحان کنید",
|
||||||
|
"imageMakerBadge": "جدید"
|
||||||
|
},
|
||||||
|
"templates": {
|
||||||
|
"heading": "بیش از ۱٬۲۰۰ قالب حرفهای",
|
||||||
|
"tabAll": "همه",
|
||||||
|
"tabVideos": "ویدیو",
|
||||||
|
"tabImages": "تصویر",
|
||||||
|
"tabSocial": "رسانه اجتماعی",
|
||||||
|
"tabBusiness": "کسبوکار",
|
||||||
|
"browseAll": "مشاهده همه قالبها",
|
||||||
|
"useTemplate": "استفاده از قالب",
|
||||||
|
"opening": "در حال باز کردن…"
|
||||||
|
},
|
||||||
|
"howItWorks": {
|
||||||
|
"heading": "چطور کار میکند",
|
||||||
|
"subtitle": "سه گام ساده از ایده تا خروجی آماده انتشار",
|
||||||
|
"step1Title": "یک قالب انتخاب کنید یا از صفر شروع کنید",
|
||||||
|
"step1Desc": "از میان بیش از ۱٬۲۰۰ قالب حرفهای برای هر صنعت، مخاطب و فرمت انتخاب کنید — یا با یک بوم خالی شروع کنید.",
|
||||||
|
"step2Title": "با ویرایشگر هوشمند هوش مصنوعی شخصیسازی کنید",
|
||||||
|
"step2Desc": "متن، رنگها و رسانهها را تغییر دهید. هوش مصنوعی پیشنهادهایی برای نوشته، رنگ و چیدمان ارائه میدهد تا سریعتر به نتیجه برسید.",
|
||||||
|
"step3Title": "با یک کلیک خروجی بگیرید و به اشتراک بگذارید",
|
||||||
|
"step3Desc": "برای اینستاگرام، تیکتاک، یوتیوب، لینکدین یا ارائههای تجاری خروجی بگیرید. فرمتهای بهینهشده برای هر پلتفرم."
|
||||||
|
},
|
||||||
|
"testimonials": {
|
||||||
|
"heading": "مورد علاقه سازندگان در سراسر جهان",
|
||||||
|
"item0Name": "سارا چن",
|
||||||
|
"item0Role": "مدیر محتوا",
|
||||||
|
"item0Company": "استودیو Bloom",
|
||||||
|
"item0Quote": "ما زمان تحویل تیزر را از سه روز به چند ساعت کاهش دادیم. پیشنهادهای ویرایشگر هوش مصنوعی فوقالعاده است.",
|
||||||
|
"item0Initials": "سچ",
|
||||||
|
"item1Name": "مارکوس وب",
|
||||||
|
"item1Role": "بنیانگذار",
|
||||||
|
"item1Company": "Launchpad SaaS",
|
||||||
|
"item1Quote": "دکهای ارائه، نمایشهای محصول و کلیپهای اجتماعی همه در یک جا هستند. تیم ما واقعاً از ساخت محتوا لذت میبرد.",
|
||||||
|
"item1Initials": "مو",
|
||||||
|
"item2Name": "النا رویز",
|
||||||
|
"item2Role": "مدیر رسانه اجتماعی",
|
||||||
|
"item2Company": "North & Co.",
|
||||||
|
"item2Quote": "کیفیت قالبها بهتر از چیزی است که پارسال به آژانسها پرداختیم. تغییر اندازه برای هر پلتفرم تقریباً خودکار است.",
|
||||||
|
"item2Initials": "ار",
|
||||||
|
"item3Name": "جیمز اوکونکو",
|
||||||
|
"item3Role": "یوتیوبر",
|
||||||
|
"item3Company": "۲۴۰ هزار مشترک",
|
||||||
|
"item3Quote": "یک هفته تامبنیل و شورت را در یک نشست آماده میکنم. خروجیها همیشه واضح و آماده آپلود هستند.",
|
||||||
|
"item3Initials": "جا",
|
||||||
|
"item4Name": "پریا نایر",
|
||||||
|
"item4Role": "طراح برند",
|
||||||
|
"item4Company": "Studio Kite",
|
||||||
|
"item4Quote": "کیت برند رنگها و فونتها را قفل میکند تا فریلنسرها از هویت برند خارج نشوند. برای کار با مشتریان عالی است.",
|
||||||
|
"item4Initials": "پن",
|
||||||
|
"item5Name": "دیوید پارک",
|
||||||
|
"item5Role": "سرپرست بازاریابی",
|
||||||
|
"item5Company": "Harbor Retail",
|
||||||
|
"item5Quote": "FlatRender را در ۱۲ فروشگاه برای آگهیهای محلی پیاده کردیم. مدیران فروشگاه بدون دانش طراحی هم میتوانند کمپین اجرا کنند.",
|
||||||
|
"item5Initials": "دپ"
|
||||||
|
},
|
||||||
|
"faq": {
|
||||||
|
"heading": "سوالات متداول",
|
||||||
|
"subtitle": "هر چیزی که باید درباره FlatRender بدانید. جواب پیدا نکردید؟ با تیم پشتیبانی ما تماس بگیرید.",
|
||||||
|
"q0": "FlatRender چیست؟",
|
||||||
|
"a0": "FlatRender یک پلتفرم خلاقانه همهجانبه برای ساخت ویدیوها و تصاویر حرفهای است. از میان بیش از ۱٬۲۰۰ قالب انتخاب کنید یا از صفر شروع کنید، با ویرایشگر هوشمند شخصیسازی کنید و در فرمتهای آماده برای رسانه اجتماعی، آگهی و ارائه خروجی بگیرید.",
|
||||||
|
"q1": "آیا رایگان است؟",
|
||||||
|
"a1": "بله. پلن رایگان شامل ۵ خروجی در ماه، خروجی ویدیوی ۷۲۰p و دسترسی به قالبهای پایه است — بدون نیاز به کارت اعتباری. برای خروجیهای نامحدود، کیفیت ۴K، ابزارهای هوش مصنوعی و قابلیتهای تیمی به Pro یا Business ارتقا دهید.",
|
||||||
|
"q2": "چه فرمتهایی میتوانم خروجی بگیرم؟",
|
||||||
|
"a2": "ویدیوها را به صورت MP4 (تا ۴K در پلنهای پولی)، تصاویر را به PNG یا JPG، و انیمیشنها را به GIF یا MP4 خروجی بگیرید. پیشتنظیمها برای اینستاگرام، تیکتاک، یوتیوب، لینکدین و ابعاد استاندارد چاپ موجود است.",
|
||||||
|
"q3": "آیا میتوانم از آن برای استفاده تجاری بهره ببرم؟",
|
||||||
|
"a3": "بله. تمام پلنهای پولی شامل مجوز تجاری برای کار با مشتری، آگهی و محتوای برندشده است. خروجیهای پلن رایگان برای پروژههای شخصی و غیرتجاری مجاز است مگر اینکه ارتقا دهید.",
|
||||||
|
"q4": "ویدیو ساز چطور کار میکند؟",
|
||||||
|
"a4": "یک قالب ویدیویی یا تایملاین خالی انتخاب کنید، صحنهها و متن را تغییر دهید، موسیقی و موشن گرافیک اضافه کنید، سپس هوش مصنوعی پیشنهادهایی برای برش و کپشن ارائه میدهد. پیشنمایش را در زمان واقعی ببینید و فایل بهینهشده برای پلتفرم هدف خروجی بگیرید.",
|
||||||
|
"q5": "تصویر ساز چطور کار میکند؟",
|
||||||
|
"a5": "از یک قالب یا بوم سفارشی شروع کنید، لایههایی مثل متن، عکس و اشکال را ویرایش کنید، و از هوش مصنوعی برای تولید پسزمینه یا تغییر اندازه چیدمان برای هر کانالی استفاده کنید. با یک کلیک تصاویر با کیفیت بالا خروجی بگیرید.",
|
||||||
|
"q6": "آیا به مهارت طراحی نیاز دارم؟",
|
||||||
|
"a6": "نه. قالبها و چیدمانهای هوشمند تایپوگرافی، فاصلهگذاری و رنگ را مدیریت میکنند. ویرایشگر با کنترلهای کشیدن و رها کردن و پیشنهادهای هوش مصنوعی شما را راهنمایی میکند تا مبتدیان و حرفهایها بتوانند کارهای باکیفیت را سریع تحویل دهند.",
|
||||||
|
"q7": "آیا میتوانم هر زمان لغو کنم؟",
|
||||||
|
"a7": "بله. هر زمان از تنظیمات حساب لغو کنید. دسترسی تا پایان دوره صورتحساب باقی میماند و میتوانید به پلن رایگان برگردید بدون اینکه پروژههایتان از دست بروند."
|
||||||
|
},
|
||||||
|
"pricing": {
|
||||||
|
"heading": "پلن FlatRender خود را انتخاب کنید",
|
||||||
|
"monthly": "ماهانه",
|
||||||
|
"annual": "سالانه",
|
||||||
|
"saveBadge": "تا {percent}٪ صرفهجویی",
|
||||||
|
"subscribe": "اشتراک",
|
||||||
|
"freeBannerTitle": "پلن رایگان",
|
||||||
|
"freeBannerDesc": "برای همیشه رایگان، بدون نیاز به کارت اعتباری",
|
||||||
|
"perMonth": "/ ماه",
|
||||||
|
"billedAnnually": "پرداخت سالانه",
|
||||||
|
"compareTitle": "مقایسه همه پلنها",
|
||||||
|
"allFeatures": "همه امکانات",
|
||||||
|
"unlimited": "نامحدود",
|
||||||
|
"liteName": "Lite",
|
||||||
|
"liteDesc": "دسترسی به امکانات ویژه برای استفاده شخصی",
|
||||||
|
"proName": "Pro",
|
||||||
|
"proDesc": "حرفهای شوید و ابزارهای قدرتمندتر ویدیو، طراحی و وبسایت را برای استفاده تجاری باز کنید.",
|
||||||
|
"businessName": "Business",
|
||||||
|
"businessDesc": "راهحل پیشرفته برای تیمها و کسبوکارها. شامل مجوز فروش مجدد."
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"brandName": "FlatRender",
|
||||||
|
"description": "ویدیوها و تصاویر حرفهای با ابزارهای هوشمند هوش مصنوعی برای سازندگان و برندها بسازید.",
|
||||||
|
"products": "محصولات",
|
||||||
|
"company": "شرکت",
|
||||||
|
"legal": "قانونی",
|
||||||
|
"rights": "© {year} FlatRender. تمامی حقوق محفوظ است.",
|
||||||
|
"madeWith": "ساختهشده با ❤️",
|
||||||
|
"videoMaker": "ویدیو ساز",
|
||||||
|
"imageMaker": "تصویر ساز",
|
||||||
|
"templates": "قالبها",
|
||||||
|
"pricingLink": "قیمتگذاری",
|
||||||
|
"about": "درباره ما",
|
||||||
|
"blog": "وبلاگ",
|
||||||
|
"careers": "فرصتهای شغلی",
|
||||||
|
"contact": "تماس با ما",
|
||||||
|
"privacy": "حریم خصوصی",
|
||||||
|
"terms": "شرایط استفاده",
|
||||||
|
"cookies": "سیاست کوکی",
|
||||||
|
"socialX": "X (توییتر)",
|
||||||
|
"socialInstagram": "اینستاگرام",
|
||||||
|
"socialLinkedIn": "لینکدین",
|
||||||
|
"socialYouTube": "یوتیوب"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"signIn": "ورود",
|
||||||
|
"signUp": "ثبتنام",
|
||||||
|
"email": "ایمیل",
|
||||||
|
"password": "رمز عبور",
|
||||||
|
"forgotPassword": "رمز عبور را فراموش کردید؟",
|
||||||
|
"noAccount": "حساب کاربری ندارید؟",
|
||||||
|
"hasAccount": "قبلاً ثبتنام کردهاید؟",
|
||||||
|
"signInHere": "اینجا وارد شوید",
|
||||||
|
"signUpHere": "اینجا ثبتنام کنید",
|
||||||
|
"continueWithGoogle": "ادامه با Google",
|
||||||
|
"or": "یا"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"myProjects": "پروژههای من",
|
||||||
|
"newProject": "پروژه جدید",
|
||||||
|
"settings": "تنظیمات",
|
||||||
|
"signOut": "خروج",
|
||||||
|
"noProjects": "هنوز پروژهای ندارید",
|
||||||
|
"noProjectsDesc": "برای شروع یک پروژه جدید بسازید",
|
||||||
|
"createFirst": "اولین پروژه را بسازید",
|
||||||
|
"video": "ویدیو",
|
||||||
|
"image": "تصویر",
|
||||||
|
"openInStudio": "باز کردن در استودیو",
|
||||||
|
"download": "دانلود",
|
||||||
|
"rename": "تغییر نام",
|
||||||
|
"duplicate": "کپی",
|
||||||
|
"delete": "حذف",
|
||||||
|
"rendering": "در حال رندر",
|
||||||
|
"ready": "آماده",
|
||||||
|
"draft": "پیشنویس",
|
||||||
|
"editedToday": "امروز ویرایش شد",
|
||||||
|
"editedYesterday": "دیروز ویرایش شد"
|
||||||
|
},
|
||||||
|
"studio": {
|
||||||
|
"newVideo": "ویدیو جدید",
|
||||||
|
"createNewVideo": "ساخت ویدیوی جدید",
|
||||||
|
"selectOption": "یکی از گزینهها را برای شروع انتخاب کنید",
|
||||||
|
"selectScenes": "انتخاب صحنهها",
|
||||||
|
"selectScenesDesc": "صحنهها را مرور کنید و پروژهتان را از صفر بسازید",
|
||||||
|
"createWithAI": "ساخت با هوش مصنوعی",
|
||||||
|
"createWithAIDesc": "ایدهها یا متن خود را به ویدیوهای تولیدشده با هوش مصنوعی تبدیل کنید",
|
||||||
|
"orStartWith": "یا با قالب شروع کنید",
|
||||||
|
"searchPresets": "جستجوی قالبها...",
|
||||||
|
"useTemplate": "استفاده از قالب"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"loading": "در حال بارگذاری...",
|
||||||
|
"error": "خطا",
|
||||||
|
"retry": "تلاش مجدد",
|
||||||
|
"cancel": "انصراف",
|
||||||
|
"save": "ذخیره",
|
||||||
|
"close": "بستن",
|
||||||
|
"back": "بازگشت",
|
||||||
|
"next": "بعدی",
|
||||||
|
"previous": "قبلی",
|
||||||
|
"search": "جستجو",
|
||||||
|
"filter": "فیلتر",
|
||||||
|
"all": "همه",
|
||||||
|
"popular": "محبوب",
|
||||||
|
"new": "جدید"
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
-3
@@ -1,4 +1,49 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
const nextConfig = {};
|
|
||||||
|
|
||||||
export default nextConfig;
|
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
||||||
|
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
webpack: (config, { isServer, webpack }) => {
|
||||||
|
if (!isServer) {
|
||||||
|
config.output.globalObject = "self";
|
||||||
|
}
|
||||||
|
|
||||||
|
// react-konva / konva must not load the Node `canvas` package in the browser bundle
|
||||||
|
config.resolve.alias = {
|
||||||
|
...config.resolve.alias,
|
||||||
|
canvas: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
config.plugins.push(
|
||||||
|
new webpack.IgnorePlugin({
|
||||||
|
resourceRegExp: /^canvas$/,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "picsum.photos",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Required for ffmpeg.wasm (SharedArrayBuffer needs COOP + COEP headers)
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/(.*)",
|
||||||
|
headers: [
|
||||||
|
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
|
||||||
|
{ key: "Cross-Origin-Embedder-Policy", value: "require-corp" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withNextIntl(nextConfig);
|
||||||
|
|||||||
Generated
+5537
-105
File diff suppressed because it is too large
Load Diff
+43
-5
@@ -6,21 +6,59 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"render-worker": "tsx server/render-worker.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@ffmpeg/ffmpeg": "^0.12.15",
|
||||||
|
"@ffmpeg/util": "^0.12.2",
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@nexrender/core": "^1.46.0",
|
||||||
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-toast": "^1.2.15",
|
||||||
|
"@supabase/ssr": "^0.10.3",
|
||||||
|
"@supabase/supabase-js": "^2.106.1",
|
||||||
|
"browser-image-compression": "^2.0.2",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.39.0",
|
||||||
|
"konva": "^9.3.22",
|
||||||
|
"lucide-react": "^1.16.0",
|
||||||
|
"next": "14.2.35",
|
||||||
|
"next-intl": "^4.12.0",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"next": "14.2.35"
|
"react-hook-form": "^7.76.0",
|
||||||
|
"react-konva": "^18.2.16",
|
||||||
|
"react-rnd": "^10.5.3",
|
||||||
|
"stripe": "^22.1.1",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"use-image": "^1.1.4",
|
||||||
|
"use-undoable": "^5.0.0",
|
||||||
|
"zod": "^4.4.3",
|
||||||
|
"zustand": "^5.0.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5",
|
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
|
"eslint": "^8",
|
||||||
|
"eslint-config-next": "14.2.35",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"eslint": "^8",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"eslint-config-next": "14.2.35"
|
"tsx": "^4.19.4",
|
||||||
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import type { RenderScene, RenderSettings } from "../src/lib/render-schemas";
|
||||||
|
import { RESOLUTION_DIMENSIONS } from "../src/lib/render-schemas";
|
||||||
|
|
||||||
|
export interface NexrenderAsset {
|
||||||
|
type: string;
|
||||||
|
layerName?: string;
|
||||||
|
composition?: string;
|
||||||
|
property?: string;
|
||||||
|
value?: string | number;
|
||||||
|
src?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
time?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NexrenderJob {
|
||||||
|
template: {
|
||||||
|
src: string;
|
||||||
|
composition: string;
|
||||||
|
frameStart?: number;
|
||||||
|
frameEnd?: number;
|
||||||
|
};
|
||||||
|
assets: NexrenderAsset[];
|
||||||
|
actions?: {
|
||||||
|
postrender?: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
onRenderProgress?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function layerToAsset(
|
||||||
|
layer: RenderScene["layers"][number],
|
||||||
|
scene: RenderScene,
|
||||||
|
sceneIndex: number
|
||||||
|
): NexrenderAsset | null {
|
||||||
|
const time = sceneIndex * scene.duration;
|
||||||
|
|
||||||
|
switch (layer.type) {
|
||||||
|
case "text": {
|
||||||
|
const text =
|
||||||
|
typeof layer.props.text === "string" ? layer.props.text : "Text";
|
||||||
|
return {
|
||||||
|
type: "data",
|
||||||
|
layerName: `Scene${sceneIndex + 1}_Text`,
|
||||||
|
composition: scene.name,
|
||||||
|
property: "Source Text",
|
||||||
|
value: text,
|
||||||
|
time,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "image": {
|
||||||
|
const src = typeof layer.props.src === "string" ? layer.props.src : "";
|
||||||
|
if (!src) return null;
|
||||||
|
return {
|
||||||
|
type: "image",
|
||||||
|
layerName: `Scene${sceneIndex + 1}_Image`,
|
||||||
|
composition: scene.name,
|
||||||
|
src,
|
||||||
|
width: layer.width,
|
||||||
|
height: layer.height,
|
||||||
|
time,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "video": {
|
||||||
|
const src = typeof layer.props.src === "string" ? layer.props.src : "";
|
||||||
|
if (!src) return null;
|
||||||
|
return {
|
||||||
|
type: "video",
|
||||||
|
layerName: `Scene${sceneIndex + 1}_Video`,
|
||||||
|
composition: scene.name,
|
||||||
|
src,
|
||||||
|
time,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "shape":
|
||||||
|
return {
|
||||||
|
type: "data",
|
||||||
|
layerName: `Scene${sceneIndex + 1}_Shape`,
|
||||||
|
composition: scene.name,
|
||||||
|
property: "Opacity",
|
||||||
|
value: Math.round(layer.opacity * 100),
|
||||||
|
time,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildNexrenderJob(
|
||||||
|
scenes: RenderScene[],
|
||||||
|
settings: RenderSettings,
|
||||||
|
jobId: string,
|
||||||
|
outputPath: string
|
||||||
|
): NexrenderJob {
|
||||||
|
const templateSrc =
|
||||||
|
process.env.NEXRENDER_TEMPLATE_SRC ??
|
||||||
|
"file:///templates/creatorstudio-base.aep";
|
||||||
|
const composition =
|
||||||
|
process.env.NEXRENDER_COMPOSITION ?? "CreatorStudio_Main";
|
||||||
|
|
||||||
|
const { width, height } = RESOLUTION_DIMENSIONS[settings.resolution];
|
||||||
|
const totalDuration = scenes.reduce((sum, scene) => sum + scene.duration, 0);
|
||||||
|
const frameEnd = Math.ceil(totalDuration * settings.fps);
|
||||||
|
|
||||||
|
const assets: NexrenderAsset[] = [
|
||||||
|
{
|
||||||
|
type: "data",
|
||||||
|
layerName: "Settings",
|
||||||
|
composition,
|
||||||
|
property: "Width",
|
||||||
|
value: width,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "data",
|
||||||
|
layerName: "Settings",
|
||||||
|
composition,
|
||||||
|
property: "Height",
|
||||||
|
value: height,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "data",
|
||||||
|
layerName: "Settings",
|
||||||
|
composition,
|
||||||
|
property: "Frame Rate",
|
||||||
|
value: settings.fps,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
scenes.forEach((scene, sceneIndex) => {
|
||||||
|
assets.push({
|
||||||
|
type: "data",
|
||||||
|
layerName: `Scene${sceneIndex + 1}`,
|
||||||
|
composition,
|
||||||
|
property: "Duration",
|
||||||
|
value: scene.duration,
|
||||||
|
time: sceneIndex * scene.duration,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedLayers = [...scene.layers].sort(
|
||||||
|
(a, b) => a.zIndex - b.zIndex
|
||||||
|
);
|
||||||
|
|
||||||
|
sortedLayers.forEach((layer) => {
|
||||||
|
const asset = layerToAsset(layer, scene, sceneIndex);
|
||||||
|
if (asset) assets.push(asset);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
template: {
|
||||||
|
src: templateSrc,
|
||||||
|
composition,
|
||||||
|
frameStart: 0,
|
||||||
|
frameEnd,
|
||||||
|
},
|
||||||
|
assets,
|
||||||
|
actions: {
|
||||||
|
postrender: [
|
||||||
|
{
|
||||||
|
module: "@nexrender/action-copy",
|
||||||
|
output: outputPath,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
jobId,
|
||||||
|
resolution: settings.resolution,
|
||||||
|
fps: settings.fps,
|
||||||
|
format: settings.format,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Vendored
+13
@@ -0,0 +1,13 @@
|
|||||||
|
declare module "@nexrender/core" {
|
||||||
|
export interface NexrenderRenderOptions {
|
||||||
|
workPath?: string;
|
||||||
|
binary?: string;
|
||||||
|
skipCleanup?: boolean;
|
||||||
|
onProgress?: (job: { metadata?: Record<string, unknown> }, percent: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function render(
|
||||||
|
job: unknown,
|
||||||
|
options?: NexrenderRenderOptions
|
||||||
|
): Promise<string | { output?: string }>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
|
import { buildNexrenderJob } from "./nexrender-job-builder";
|
||||||
|
import type { RenderScene, RenderSettings } from "../src/lib/render-schemas";
|
||||||
|
|
||||||
|
function getSupabase() {
|
||||||
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
if (!url || !key) {
|
||||||
|
throw new Error("Supabase env vars required for render worker");
|
||||||
|
}
|
||||||
|
return createClient(url, key, {
|
||||||
|
auth: { autoRefreshToken: false, persistSession: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateJob(
|
||||||
|
jobId: string,
|
||||||
|
updates: Record<string, unknown>
|
||||||
|
): Promise<void> {
|
||||||
|
const supabase = getSupabase();
|
||||||
|
await supabase.from("render_jobs").update(updates).eq("id", jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadToStorage(
|
||||||
|
jobId: string,
|
||||||
|
filePath: string
|
||||||
|
): Promise<string> {
|
||||||
|
const supabase = getSupabase();
|
||||||
|
const buffer = await readFile(filePath);
|
||||||
|
const storagePath = `${jobId}/output.mp4`;
|
||||||
|
|
||||||
|
const { error } = await supabase.storage
|
||||||
|
.from("renders")
|
||||||
|
.upload(storagePath, buffer, {
|
||||||
|
contentType: "video/mp4",
|
||||||
|
upsert: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
const { data } = supabase.storage.from("renders").getPublicUrl(storagePath);
|
||||||
|
return data.publicUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitToNexrenderServer(
|
||||||
|
job: ReturnType<typeof buildNexrenderJob>
|
||||||
|
): Promise<string> {
|
||||||
|
const serverUrl = process.env.NEXRENDER_SERVER_URL;
|
||||||
|
if (!serverUrl) {
|
||||||
|
throw new Error("NEXRENDER_SERVER_URL not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${serverUrl.replace(/\/$/, "")}/api/v1/jobs`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(job),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Nexrender server error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as { uid?: string; id?: string };
|
||||||
|
return payload.uid ?? payload.id ?? "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderWithCore(
|
||||||
|
job: ReturnType<typeof buildNexrenderJob>,
|
||||||
|
onProgress: (percent: number, message: string) => Promise<void>
|
||||||
|
): Promise<string> {
|
||||||
|
const { render } = await import("@nexrender/core");
|
||||||
|
const workPath =
|
||||||
|
process.env.NEXRENDER_WORKPATH ?? path.join(process.cwd(), ".nexrender");
|
||||||
|
|
||||||
|
await onProgress(10, "Starting After Effects render…");
|
||||||
|
|
||||||
|
const result = await render(job, {
|
||||||
|
workPath,
|
||||||
|
binary: process.env.NEXRENDER_BINARY,
|
||||||
|
skipCleanup: false,
|
||||||
|
onProgress: (
|
||||||
|
nexJob: { metadata?: Record<string, unknown> },
|
||||||
|
percent: number
|
||||||
|
) => {
|
||||||
|
const label = nexJob.metadata?.progressMessage as string | undefined;
|
||||||
|
void onProgress(
|
||||||
|
Math.min(95, Math.round(percent * 100)),
|
||||||
|
label ?? "Rendering composition…"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputPath =
|
||||||
|
typeof result === "string"
|
||||||
|
? result
|
||||||
|
: ((result as { output?: string })?.output ??
|
||||||
|
job.actions?.postrender?.[0]?.output);
|
||||||
|
|
||||||
|
if (!outputPath || typeof outputPath !== "string") {
|
||||||
|
throw new Error("Nexrender did not return output path");
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockRender(
|
||||||
|
scenes: RenderScene[],
|
||||||
|
onProgress: (percent: number, message: string) => Promise<void>
|
||||||
|
): Promise<string> {
|
||||||
|
const total = scenes.length;
|
||||||
|
for (let i = 0; i < total; i += 1) {
|
||||||
|
const percent = Math.round(((i + 1) / total) * 90);
|
||||||
|
await onProgress(
|
||||||
|
percent,
|
||||||
|
`Rendering scene ${i + 1} of ${total}…`
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||||
|
}
|
||||||
|
await onProgress(95, "Encoding MP4…");
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processRenderJob(jobId: string): Promise<void> {
|
||||||
|
const supabase = getSupabase();
|
||||||
|
const { data: row, error } = await supabase
|
||||||
|
.from("render_jobs")
|
||||||
|
.select("*")
|
||||||
|
.eq("id", jobId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error || !row) {
|
||||||
|
throw new Error(`Job ${jobId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scenes = row.scenes as RenderScene[];
|
||||||
|
const settings = row.settings as RenderSettings;
|
||||||
|
const totalScenes = scenes.length;
|
||||||
|
|
||||||
|
const onProgress = async (percent: number, message: string) => {
|
||||||
|
await updateJob(jobId, {
|
||||||
|
status: "processing",
|
||||||
|
progress: percent,
|
||||||
|
progress_message: message,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateJob(jobId, {
|
||||||
|
status: "processing",
|
||||||
|
progress: 2,
|
||||||
|
progress_message: "Preparing render…",
|
||||||
|
});
|
||||||
|
|
||||||
|
const workDir = process.env.NEXRENDER_WORKPATH ?? path.join(process.cwd(), ".nexrender");
|
||||||
|
const outputPath = path.join(workDir, "output", `${jobId}.mp4`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nexrenderJob = buildNexrenderJob(
|
||||||
|
scenes,
|
||||||
|
settings,
|
||||||
|
jobId,
|
||||||
|
outputPath
|
||||||
|
);
|
||||||
|
|
||||||
|
let renderedPath = "";
|
||||||
|
|
||||||
|
const useMock =
|
||||||
|
process.env.RENDER_MOCK === "true" ||
|
||||||
|
(!process.env.NEXRENDER_SERVER_URL &&
|
||||||
|
!process.env.NEXRENDER_BINARY &&
|
||||||
|
!process.env.NEXRENDER_TEMPLATE_SRC);
|
||||||
|
|
||||||
|
if (useMock) {
|
||||||
|
await mockRender(scenes, onProgress);
|
||||||
|
await onProgress(96, "Uploading to storage…");
|
||||||
|
const placeholder = Buffer.from(
|
||||||
|
"Mock render — configure NEXRENDER_BINARY or RENDER_MOCK=false"
|
||||||
|
);
|
||||||
|
const storagePath = `${jobId}/output.mp4`;
|
||||||
|
await supabase.storage.from("renders").upload(storagePath, placeholder, {
|
||||||
|
contentType: "text/plain",
|
||||||
|
upsert: true,
|
||||||
|
});
|
||||||
|
const { data: urlData } = supabase.storage
|
||||||
|
.from("renders")
|
||||||
|
.getPublicUrl(storagePath);
|
||||||
|
await updateJob(jobId, {
|
||||||
|
status: "completed",
|
||||||
|
progress: 100,
|
||||||
|
progress_message: "Render complete (mock)",
|
||||||
|
output_url: urlData.publicUrl,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NEXRENDER_SERVER_URL) {
|
||||||
|
await onProgress(15, "Submitting to nexrender server…");
|
||||||
|
const uid = await submitToNexrenderServer(nexrenderJob);
|
||||||
|
await onProgress(25, `Nexrender job ${uid} started…`);
|
||||||
|
|
||||||
|
for (let i = 0; i < totalScenes; i += 1) {
|
||||||
|
await onProgress(
|
||||||
|
25 + Math.round(((i + 1) / totalScenes) * 60),
|
||||||
|
`Rendering scene ${i + 1} of ${totalScenes}…`
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||||
|
}
|
||||||
|
|
||||||
|
renderedPath = outputPath;
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < totalScenes; i += 1) {
|
||||||
|
await onProgress(
|
||||||
|
10 + Math.round((i / totalScenes) * 20),
|
||||||
|
`Rendering scene ${i + 1} of ${totalScenes}…`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
renderedPath = await renderWithCore(nexrenderJob, onProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
await onProgress(96, "Uploading to storage…");
|
||||||
|
const publicUrl = await uploadToStorage(jobId, renderedPath);
|
||||||
|
|
||||||
|
await updateJob(jobId, {
|
||||||
|
status: "completed",
|
||||||
|
progress: 100,
|
||||||
|
progress_message: "Render complete",
|
||||||
|
output_url: publicUrl,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : "Unknown render error";
|
||||||
|
await updateJob(jobId, {
|
||||||
|
status: "failed",
|
||||||
|
progress: 0,
|
||||||
|
progress_message: "Render failed",
|
||||||
|
error_message: message,
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Standalone render worker — run: npm run render-worker
|
||||||
|
* POST /process { jobId } — requires RENDER_WORKER_SECRET if set
|
||||||
|
*/
|
||||||
|
import http from "node:http";
|
||||||
|
|
||||||
|
import { processRenderJob } from "./render-job-processor";
|
||||||
|
|
||||||
|
const PORT = Number(process.env.RENDER_WORKER_PORT ?? 3355);
|
||||||
|
const SECRET = process.env.RENDER_WORKER_SECRET;
|
||||||
|
|
||||||
|
function isAuthorized(request: http.IncomingMessage): boolean {
|
||||||
|
if (!SECRET) return true;
|
||||||
|
const header = request.headers.authorization;
|
||||||
|
return header === `Bearer ${SECRET}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody(request: http.IncomingMessage): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
request.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
||||||
|
request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
||||||
|
request.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(async (request, response) => {
|
||||||
|
const url = request.url ?? "/";
|
||||||
|
|
||||||
|
if (request.method === "GET" && url === "/health") {
|
||||||
|
response.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
response.end(JSON.stringify({ ok: true }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "POST" && url === "/process") {
|
||||||
|
if (!isAuthorized(request)) {
|
||||||
|
response.writeHead(401, { "Content-Type": "application/json" });
|
||||||
|
response.end(JSON.stringify({ error: "Unauthorized" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(await readBody(request)) as { jobId?: string };
|
||||||
|
if (!body.jobId) {
|
||||||
|
response.writeHead(400, { "Content-Type": "application/json" });
|
||||||
|
response.end(JSON.stringify({ error: "jobId required" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.writeHead(202, { "Content-Type": "application/json" });
|
||||||
|
response.end(JSON.stringify({ accepted: true, jobId: body.jobId }));
|
||||||
|
|
||||||
|
void processRenderJob(body.jobId).catch((err) => {
|
||||||
|
console.error(`Render job ${body.jobId} failed:`, err);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
response.writeHead(400, { "Content-Type": "application/json" });
|
||||||
|
response.end(JSON.stringify({ error: "Invalid JSON" }));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.writeHead(404, { "Content-Type": "application/json" });
|
||||||
|
response.end(JSON.stringify({ error: "Not found" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`Render worker listening on http://localhost:${PORT}`);
|
||||||
|
console.log("Endpoints: GET /health, POST /process");
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
|
import { AuthLoadingSpinner } from "@/components/auth/AuthLoadingSpinner";
|
||||||
|
import { AuthPageContent } from "@/components/auth/AuthPageContent";
|
||||||
|
import { createPageMetadata } from "@/lib/metadata";
|
||||||
|
|
||||||
|
export const metadata: Metadata = createPageMetadata({
|
||||||
|
title: "Sign In",
|
||||||
|
description: "Sign in or create your CreatorStudio account.",
|
||||||
|
path: "/auth",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function AuthPage() {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-neutral-50">
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center py-20">
|
||||||
|
<AuthLoadingSpinner label="Loading..." />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AuthPageContent />
|
||||||
|
</Suspense>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { DashboardShell } from "@/components/dashboard/DashboardShell";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function DashboardLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
redirect("/auth");
|
||||||
|
}
|
||||||
|
|
||||||
|
const userName =
|
||||||
|
typeof user.user_metadata?.full_name === "string"
|
||||||
|
? user.user_metadata.full_name
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardShell
|
||||||
|
userEmail={user.email ?? ""}
|
||||||
|
userName={userName}
|
||||||
|
userId={user.id}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DashboardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
|
import { DashboardProjectsContent } from "@/components/dashboard/DashboardProjectsContent";
|
||||||
|
import { DashboardProjectsSection } from "@/components/dashboard/DashboardProjectsSection";
|
||||||
|
import { createPageMetadata } from "@/lib/metadata";
|
||||||
|
|
||||||
|
export const metadata: Metadata = createPageMetadata({
|
||||||
|
title: "Dashboard",
|
||||||
|
description: "Your CreatorStudio workspace — projects, templates, and tools.",
|
||||||
|
path: "/dashboard",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<DashboardProjectsSection isLoading />}>
|
||||||
|
<DashboardProjectsContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
import { createPageMetadata } from "@/lib/metadata";
|
||||||
|
|
||||||
|
export const metadata: Metadata = createPageMetadata({
|
||||||
|
title: "Settings",
|
||||||
|
description: "Manage your CreatorStudio account and workspace preferences.",
|
||||||
|
path: "/dashboard/settings",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function DashboardSettingsPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
interface ErrorPageProps {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ErrorPage({ error, reset }: ErrorPageProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
// Surface to monitoring in production when configured
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen flex-col items-center justify-center bg-white px-4 text-center">
|
||||||
|
<h1 className="font-heading text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||||
|
Something went wrong
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 max-w-md text-sm text-neutral-600 sm:text-base">
|
||||||
|
An unexpected error occurred. Try reloading the page.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="mt-8 bg-blue-600 hover:bg-blue-700"
|
||||||
|
onClick={() => reset()}
|
||||||
|
>
|
||||||
|
Reload page
|
||||||
|
</Button>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
import { ImageMakerCta } from "@/components/image-maker/ImageMakerCta";
|
||||||
|
import { ImageMakerFeatures } from "@/components/image-maker/ImageMakerFeatures";
|
||||||
|
import { ImageMakerGallery } from "@/components/image-maker/ImageMakerGallery";
|
||||||
|
import { ImageMakerHero } from "@/components/image-maker/ImageMakerHero";
|
||||||
|
import { ImageMakerUseCases } from "@/components/image-maker/ImageMakerUseCases";
|
||||||
|
import { createPageMetadata } from "@/lib/metadata";
|
||||||
|
|
||||||
|
export const metadata: Metadata = createPageMetadata({
|
||||||
|
title: "AI Image Maker",
|
||||||
|
description:
|
||||||
|
"Design professional visuals instantly with AI generation, templates, brand kits, and batch export.",
|
||||||
|
path: "/image-maker",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function ImageMakerPage() {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<ImageMakerHero />
|
||||||
|
<ImageMakerFeatures />
|
||||||
|
<ImageMakerUseCases />
|
||||||
|
<ImageMakerGallery />
|
||||||
|
<ImageMakerCta />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Inter, Plus_Jakarta_Sans, Vazirmatn } from "next/font/google";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getMessages, getTranslations } from "next-intl/server";
|
||||||
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
|
|
||||||
|
import { SiteChrome } from "@/components/layout/SiteChrome";
|
||||||
|
import { routing } from "@/i18n/routing";
|
||||||
|
import type { Locale } from "@/i18n/routing";
|
||||||
|
|
||||||
|
import "../globals.css";
|
||||||
|
|
||||||
|
/* ── Fonts ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const vazirmatn = Vazirmatn({
|
||||||
|
subsets: ["arabic"],
|
||||||
|
variable: "--font-vazirmatn",
|
||||||
|
display: "swap",
|
||||||
|
weight: ["400", "500", "600", "700", "800"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const plusJakartaSans = Plus_Jakarta_Sans({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-heading",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
|
const inter = Inter({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-body",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Metadata ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: Locale }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
|
const t = await getTranslations({ locale, namespace: "metadata" });
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: {
|
||||||
|
default: t("homeTitle"),
|
||||||
|
template: `%s — FlatRender`,
|
||||||
|
},
|
||||||
|
description: t("homeDescription"),
|
||||||
|
metadataBase: new URL("https://flatrender.com"),
|
||||||
|
openGraph: {
|
||||||
|
siteName: "FlatRender",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return routing.locales.map((locale) => ({ locale }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Layout ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
interface LocaleLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ locale: Locale }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LocaleLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: LocaleLayoutProps) {
|
||||||
|
const { locale } = await params;
|
||||||
|
|
||||||
|
// Validate locale — show 404 for unknown values
|
||||||
|
if (!(routing.locales as readonly string[]).includes(locale)) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = await getMessages();
|
||||||
|
const isRtl = locale === "fa";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Font class strategy:
|
||||||
|
* - Persian (fa): Vazirmatn handles both Arabic/Persian + Latin fallback
|
||||||
|
* - English (en): Plus Jakarta Sans (headings) + Inter (body)
|
||||||
|
*/
|
||||||
|
const fontVars = isRtl
|
||||||
|
? `${vazirmatn.variable}`
|
||||||
|
: `${plusJakartaSans.variable} ${inter.variable} ${vazirmatn.variable}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html
|
||||||
|
lang={locale}
|
||||||
|
dir={isRtl ? "rtl" : "ltr"}
|
||||||
|
suppressHydrationWarning
|
||||||
|
className={fontVars}
|
||||||
|
>
|
||||||
|
<head>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link
|
||||||
|
rel="preconnect"
|
||||||
|
href="https://fonts.gstatic.com"
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
/>
|
||||||
|
<link rel="preconnect" href="https://picsum.photos" />
|
||||||
|
</head>
|
||||||
|
<body
|
||||||
|
className={`min-h-screen bg-white text-neutral-900 dark:bg-neutral-950 dark:text-neutral-50 ${
|
||||||
|
isRtl ? "font-vazirmatn" : "font-body"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<NextIntlClientProvider messages={messages} locale={locale}>
|
||||||
|
<SiteChrome>{children}</SiteChrome>
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { createPageMetadata } from "@/lib/metadata";
|
||||||
|
|
||||||
|
export const metadata: Metadata = createPageMetadata({
|
||||||
|
title: "Page Not Found",
|
||||||
|
description: "The page you requested could not be found on FlatRender.",
|
||||||
|
path: "/404",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function NotFoundPage() {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen flex-col items-center justify-center bg-white px-4 text-center">
|
||||||
|
<h1 className="font-heading text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||||
|
Page not found
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 max-w-md text-sm text-neutral-600 sm:text-base">
|
||||||
|
The page you are looking for does not exist or may have been moved.
|
||||||
|
</p>
|
||||||
|
<Button asChild className="mt-8 bg-blue-600 hover:bg-blue-700">
|
||||||
|
<Link href="/">Go home</Link>
|
||||||
|
</Button>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
import { Hero } from "@/components/sections/Hero";
|
||||||
|
import { HowItWorks } from "@/components/sections/HowItWorks";
|
||||||
|
import { Pricing } from "@/components/sections/Pricing";
|
||||||
|
import { ProductsShowcase } from "@/components/sections/ProductsShowcase";
|
||||||
|
import { TemplateGallery } from "@/components/sections/TemplateGallery";
|
||||||
|
import { FAQ } from "@/components/sections/FAQ";
|
||||||
|
import { Testimonials } from "@/components/sections/Testimonials";
|
||||||
|
import { createPageMetadata } from "@/lib/metadata";
|
||||||
|
|
||||||
|
export const metadata: Metadata = createPageMetadata({
|
||||||
|
title: "Create Pro Videos & Images with AI",
|
||||||
|
description:
|
||||||
|
"FlatRender helps creators and brands make professional videos and images with AI templates, editors, and one-click export.",
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<Hero />
|
||||||
|
<ProductsShowcase />
|
||||||
|
<TemplateGallery />
|
||||||
|
<HowItWorks />
|
||||||
|
<Pricing />
|
||||||
|
<Testimonials />
|
||||||
|
<FAQ />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
import { Pricing } from "@/components/sections/Pricing";
|
||||||
|
import { createPageMetadata } from "@/lib/metadata";
|
||||||
|
|
||||||
|
export const metadata: Metadata = createPageMetadata({
|
||||||
|
title: "Pricing",
|
||||||
|
description:
|
||||||
|
"Compare FlatRender Lite, Pro, and Business plans. Monthly or yearly billing with templates, exports, and AI tools for creators.",
|
||||||
|
path: "/pricing",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function PricingPage() {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<Pricing className="pt-24" />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
const ImageEditorLayout = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/components/image-editor/ImageEditorLayout").then(
|
||||||
|
(mod) => mod.ImageEditorLayout
|
||||||
|
),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="flex h-screen w-screen items-center justify-center bg-gray-950 text-sm text-gray-500">
|
||||||
|
Loading editor…
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
interface ImageStudioPageProps {
|
||||||
|
params: {
|
||||||
|
projectId: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ImageStudioPage({ params }: ImageStudioPageProps) {
|
||||||
|
return <ImageEditorLayout projectId={params.projectId} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
import { createPageMetadata } from "@/lib/metadata";
|
||||||
|
|
||||||
|
export const metadata: Metadata = createPageMetadata({
|
||||||
|
title: "Image Editor",
|
||||||
|
description:
|
||||||
|
"Edit images with layers, adjustments, filters, drawing tools, and AI background removal.",
|
||||||
|
path: "/studio/image",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function ImageEditorLayoutRoute({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function ImageStudioIndexPage() {
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
import { createPageMetadata } from "@/lib/metadata";
|
||||||
|
|
||||||
|
export const metadata: Metadata = createPageMetadata({
|
||||||
|
title: "Video Trimmer & Cropper",
|
||||||
|
description:
|
||||||
|
"Trim and crop videos in the browser with frame previews and FFmpeg export to MP4 or WebM.",
|
||||||
|
path: "/studio/trimmer",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function TrimmerLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft, Scissors } from "lucide-react";
|
||||||
|
|
||||||
|
import { TrimmerExportSection } from "@/components/trimmer/TrimmerExportSection";
|
||||||
|
import { TrimmerStrip } from "@/components/trimmer/TrimmerStrip";
|
||||||
|
import { TrimmerUploadZone } from "@/components/trimmer/TrimmerUploadZone";
|
||||||
|
import { TrimmerVideoPreview } from "@/components/trimmer/TrimmerVideoPreview";
|
||||||
|
import {
|
||||||
|
preloadFfmpegWorker,
|
||||||
|
processTrimmedVideoInWorker,
|
||||||
|
} from "@/lib/ffmpeg-worker-client";
|
||||||
|
import type {
|
||||||
|
AspectRatioPreset,
|
||||||
|
CropBox,
|
||||||
|
ExportFormat,
|
||||||
|
} from "@/lib/trimmer-types";
|
||||||
|
import { parseFfmpegProgress } from "@/lib/trimmer-utils";
|
||||||
|
|
||||||
|
const INITIAL_CROP: CropBox = { x: 0, y: 0, w: 320, h: 180 };
|
||||||
|
|
||||||
|
export default function VideoTrimmerPage() {
|
||||||
|
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
||||||
|
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [trimStart, setTrimStart] = useState(0);
|
||||||
|
const [trimEnd, setTrimEnd] = useState(0);
|
||||||
|
const [cropBox, setCropBox] = useState<CropBox>(INITIAL_CROP);
|
||||||
|
const [aspectRatio, setAspectRatio] = useState<AspectRatioPreset>("free");
|
||||||
|
const [displaySize, setDisplaySize] = useState({ width: 0, height: 0 });
|
||||||
|
const [videoSize, setVideoSize] = useState({ width: 0, height: 0 });
|
||||||
|
const [exportFormat, setExportFormat] = useState<ExportFormat>("mp4");
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [outputUrl, setOutputUrl] = useState<string | null>(null);
|
||||||
|
const [ffmpegReady, setFfmpegReady] = useState(false);
|
||||||
|
const [ffmpegError, setFfmpegError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
preloadFfmpegWorker()
|
||||||
|
.then(() => {
|
||||||
|
if (!cancelled) setFfmpegReady(true);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setFfmpegError(
|
||||||
|
"Failed to load FFmpeg. Check your connection and try again."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (videoUrl) URL.revokeObjectURL(videoUrl);
|
||||||
|
if (outputUrl) URL.revokeObjectURL(outputUrl);
|
||||||
|
};
|
||||||
|
}, [videoUrl, outputUrl]);
|
||||||
|
|
||||||
|
const handleFileSelect = useCallback(
|
||||||
|
(file: File) => {
|
||||||
|
if (videoUrl) URL.revokeObjectURL(videoUrl);
|
||||||
|
if (outputUrl) URL.revokeObjectURL(outputUrl);
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
setUploadedFile(file);
|
||||||
|
setVideoUrl(url);
|
||||||
|
setOutputUrl(null);
|
||||||
|
setProgress(0);
|
||||||
|
setTrimStart(0);
|
||||||
|
setTrimEnd(0);
|
||||||
|
setDuration(0);
|
||||||
|
setCropBox(INITIAL_CROP);
|
||||||
|
},
|
||||||
|
[videoUrl, outputUrl]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleVideoMetadata = useCallback(
|
||||||
|
(videoDuration: number, size: { width: number; height: number }) => {
|
||||||
|
setDuration(videoDuration);
|
||||||
|
setTrimStart(0);
|
||||||
|
setTrimEnd(videoDuration);
|
||||||
|
setVideoSize(size);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTrimChange = useCallback((start: number, end: number) => {
|
||||||
|
setTrimStart(Math.max(0, start));
|
||||||
|
setTrimEnd(end);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleProcess = useCallback(async () => {
|
||||||
|
if (!uploadedFile || !ffmpegReady || displaySize.width <= 0) return;
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
setProgress(0);
|
||||||
|
if (outputUrl) URL.revokeObjectURL(outputUrl);
|
||||||
|
setOutputUrl(null);
|
||||||
|
|
||||||
|
const clipDuration = trimEnd - trimStart;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blob = await processTrimmedVideoInWorker({
|
||||||
|
file: uploadedFile,
|
||||||
|
trimStart,
|
||||||
|
trimEnd,
|
||||||
|
cropBox,
|
||||||
|
displaySize,
|
||||||
|
videoSize,
|
||||||
|
exportFormat,
|
||||||
|
onProgress: setProgress,
|
||||||
|
onLog: (message) => {
|
||||||
|
const parsed = parseFfmpegProgress(message);
|
||||||
|
if (parsed !== null && clipDuration > 0) {
|
||||||
|
setProgress(
|
||||||
|
Math.min(99, Math.round((parsed / clipDuration) * 100))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setOutputUrl(URL.createObjectURL(blob));
|
||||||
|
} catch {
|
||||||
|
setFfmpegError("Processing failed. Try a shorter clip or different format.");
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
uploadedFile,
|
||||||
|
ffmpegReady,
|
||||||
|
displaySize,
|
||||||
|
trimStart,
|
||||||
|
trimEnd,
|
||||||
|
cropBox,
|
||||||
|
videoSize,
|
||||||
|
exportFormat,
|
||||||
|
outputUrl,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-900 text-white">
|
||||||
|
<header className="border-b border-gray-800 bg-gray-950">
|
||||||
|
<div className="mx-auto flex max-w-5xl items-center gap-3 px-4 py-4">
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="flex items-center gap-1 rounded-md text-sm text-gray-400 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" aria-hidden />
|
||||||
|
Back
|
||||||
|
</Link>
|
||||||
|
<Scissors className="h-5 w-5 text-blue-500" aria-hidden />
|
||||||
|
<h1 className="font-heading text-lg font-semibold text-white">
|
||||||
|
Video Trimmer & Cropper
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto max-w-5xl space-y-6 px-4 py-8">
|
||||||
|
<TrimmerUploadZone
|
||||||
|
uploadedFile={uploadedFile}
|
||||||
|
onFileSelect={handleFileSelect}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{ffmpegError ? (
|
||||||
|
<p className="rounded-lg border border-red-800 bg-red-950/50 px-4 py-3 text-sm text-red-300">
|
||||||
|
{ffmpegError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{videoUrl ? (
|
||||||
|
<>
|
||||||
|
<TrimmerVideoPreview
|
||||||
|
videoUrl={videoUrl}
|
||||||
|
aspectRatio={aspectRatio}
|
||||||
|
cropBox={cropBox}
|
||||||
|
onCropChange={setCropBox}
|
||||||
|
onAspectRatioChange={setAspectRatio}
|
||||||
|
onVideoMetadata={handleVideoMetadata}
|
||||||
|
onDisplaySize={setDisplaySize}
|
||||||
|
/>
|
||||||
|
<TrimmerStrip
|
||||||
|
videoUrl={videoUrl}
|
||||||
|
duration={duration}
|
||||||
|
trimStart={trimStart}
|
||||||
|
trimEnd={trimEnd}
|
||||||
|
onTrimChange={handleTrimChange}
|
||||||
|
/>
|
||||||
|
<TrimmerExportSection
|
||||||
|
exportFormat={exportFormat}
|
||||||
|
onExportFormatChange={setExportFormat}
|
||||||
|
isProcessing={isProcessing}
|
||||||
|
progress={progress}
|
||||||
|
ffmpegReady={ffmpegReady}
|
||||||
|
hasVideo={Boolean(uploadedFile)}
|
||||||
|
outputUrl={outputUrl}
|
||||||
|
onProcess={handleProcess}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
import { createPageMetadata } from "@/lib/metadata";
|
||||||
|
|
||||||
|
export const metadata: Metadata = createPageMetadata({
|
||||||
|
title: "Video Studio",
|
||||||
|
description:
|
||||||
|
"Edit multi-scene video projects with layers, timeline, transitions, and export.",
|
||||||
|
path: "/studio/video",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function VideoStudioProjectLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
const VideoStudioLayout = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/components/studio/video/VideoStudioLayout").then(
|
||||||
|
(mod) => mod.VideoStudioLayout
|
||||||
|
),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="flex h-screen w-screen items-center justify-center bg-gray-900 text-sm text-gray-500">
|
||||||
|
Loading studio…
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
interface VideoStudioPageProps {
|
||||||
|
params: {
|
||||||
|
projectId: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VideoStudioPage({ params }: VideoStudioPageProps) {
|
||||||
|
return <VideoStudioLayout projectId={params.projectId} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function VideoProjectNewLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
redirect("/auth");
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
import { VideoProjectNewContent } from "@/components/studio/video/VideoProjectNewContent";
|
||||||
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
|
import { createPageMetadata } from "@/lib/metadata";
|
||||||
|
|
||||||
|
export const metadata: Metadata = createPageMetadata({
|
||||||
|
title: "Create New Video",
|
||||||
|
description:
|
||||||
|
"Start a new video project from scenes, AI, or ready-made presets.",
|
||||||
|
path: "/studio/video/new",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function VideoProjectNewPage() {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-gray-50">
|
||||||
|
<Toaster />
|
||||||
|
<VideoProjectNewContent />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
import { TemplateDetailContent } from "@/components/templates/TemplateDetailContent";
|
||||||
|
import { VIDEO_TEMPLATES_CATALOG } from "@/lib/video-templates-catalog";
|
||||||
|
|
||||||
|
interface TemplateDetailPageProps {
|
||||||
|
params: { id: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return VIDEO_TEMPLATES_CATALOG.map((template) => ({ id: template.id }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateMetadata({ params }: TemplateDetailPageProps): Metadata {
|
||||||
|
const template = VIDEO_TEMPLATES_CATALOG.find((item) => item.id === params.id);
|
||||||
|
if (!template) return {};
|
||||||
|
return { title: `${template.name} — FlatRender` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TemplateDetailPage({ params }: TemplateDetailPageProps) {
|
||||||
|
const template = VIDEO_TEMPLATES_CATALOG.find((item) => item.id === params.id);
|
||||||
|
if (!template) notFound();
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-white">
|
||||||
|
<TemplateDetailContent template={template} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
import { TemplatesPageContent } from "@/components/templates/TemplatesPageContent";
|
||||||
|
import { createPageMetadata } from "@/lib/metadata";
|
||||||
|
|
||||||
|
export const metadata: Metadata = createPageMetadata({
|
||||||
|
title: "Video Templates",
|
||||||
|
description:
|
||||||
|
"Search thousands of professional video templates. Filter by category, aspect ratio, duration, and premium features.",
|
||||||
|
path: "/templates",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function TemplatesPage() {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-white">
|
||||||
|
<TemplatesPageContent />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
import { VideoMakerCta } from "@/components/video-maker/VideoMakerCta";
|
||||||
|
import { VideoMakerFeatures } from "@/components/video-maker/VideoMakerFeatures";
|
||||||
|
import { VideoMakerHero } from "@/components/video-maker/VideoMakerHero";
|
||||||
|
import { VideoMakerTemplateCarousel } from "@/components/video-maker/VideoMakerTemplateCarousel";
|
||||||
|
import { VideoMakerUseCases } from "@/components/video-maker/VideoMakerUseCases";
|
||||||
|
import { createPageMetadata } from "@/lib/metadata";
|
||||||
|
|
||||||
|
export const metadata: Metadata = createPageMetadata({
|
||||||
|
title: "AI Video Maker",
|
||||||
|
description:
|
||||||
|
"Create stunning videos in minutes with AI scripts, auto-subtitles, 500+ templates, and 1-click export.",
|
||||||
|
path: "/video-maker",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function VideoMakerPage() {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<VideoMakerHero />
|
||||||
|
<VideoMakerFeatures />
|
||||||
|
<VideoMakerUseCases />
|
||||||
|
<VideoMakerTemplateCarousel />
|
||||||
|
<VideoMakerCta />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type { BillingPeriod } from "@/components/sections/pricing-data";
|
||||||
|
import { getStripePriceId, isPaidPlanId } from "@/lib/plans";
|
||||||
|
import { getStripe } from "@/lib/stripe";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
|
const checkoutSchema = z.object({
|
||||||
|
plan: z.enum(["pro", "business"]),
|
||||||
|
billing: z.enum(["monthly", "annual"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!user?.email) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "You must be signed in to checkout." },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: unknown = await request.json();
|
||||||
|
const parsed = checkoutSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid plan or billing period." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { plan, billing } = parsed.data;
|
||||||
|
|
||||||
|
if (!isPaidPlanId(plan)) {
|
||||||
|
return NextResponse.json({ error: "Invalid plan." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const priceId = getStripePriceId(plan, billing as BillingPeriod);
|
||||||
|
const siteUrl =
|
||||||
|
process.env.NEXT_PUBLIC_SITE_URL ?? new URL(request.url).origin;
|
||||||
|
|
||||||
|
const stripe = getStripe();
|
||||||
|
|
||||||
|
const session = await stripe.checkout.sessions.create({
|
||||||
|
mode: "subscription",
|
||||||
|
payment_method_types: ["card"],
|
||||||
|
line_items: [
|
||||||
|
{
|
||||||
|
price: priceId,
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
success_url: `${siteUrl}/dashboard?checkout=success`,
|
||||||
|
cancel_url: `${siteUrl}/#pricing`,
|
||||||
|
customer_email: user.email,
|
||||||
|
client_reference_id: user.id,
|
||||||
|
metadata: {
|
||||||
|
userId: user.id,
|
||||||
|
planId: plan,
|
||||||
|
billingPeriod: billing,
|
||||||
|
},
|
||||||
|
subscription_data: {
|
||||||
|
metadata: {
|
||||||
|
userId: user.id,
|
||||||
|
planId: plan,
|
||||||
|
billingPeriod: billing,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session.url) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to create checkout session." },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ url: session.url });
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Checkout failed.";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type { ProjectRow } from "@/lib/projects";
|
||||||
|
import { isDevProjectId } from "@/lib/project-ids";
|
||||||
|
import { isSupabaseConfigured } from "@/lib/supabase/config";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const patchProjectSchema = z.object({
|
||||||
|
scene_data: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
name: z.string().min(1).max(120).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface RouteContext {
|
||||||
|
params: { projectId: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(_request: Request, context: RouteContext) {
|
||||||
|
const { projectId } = context.params;
|
||||||
|
|
||||||
|
if (isDevProjectId(projectId)) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSupabaseConfigured()) {
|
||||||
|
if (process.env.NODE_ENV === "production") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Supabase is not configured", code: "SUPABASE_NOT_CONFIGURED" },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("projects")
|
||||||
|
.select("*")
|
||||||
|
.eq("id", projectId)
|
||||||
|
.eq("user_id", user.id)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = data as ProjectRow;
|
||||||
|
return NextResponse.json({
|
||||||
|
project: {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
type: row.type,
|
||||||
|
scene_data: row.scene_data,
|
||||||
|
status: row.status,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: Request, context: RouteContext) {
|
||||||
|
const { projectId } = context.params;
|
||||||
|
|
||||||
|
if (isDevProjectId(projectId)) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = patchProjectSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Validation failed", details: parsed.error.flatten() },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed.data.scene_data && !parsed.data.name) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Nothing to update" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSupabaseConfigured()) {
|
||||||
|
if (process.env.NODE_ENV === "production") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Supabase is not configured", code: "SUPABASE_NOT_CONFIGURED" },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: Record<string, unknown> = {
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
if (parsed.data.scene_data !== undefined) {
|
||||||
|
updates.scene_data = parsed.data.scene_data;
|
||||||
|
}
|
||||||
|
if (parsed.data.name !== undefined) {
|
||||||
|
updates.name = parsed.data.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("projects")
|
||||||
|
.update(updates)
|
||||||
|
.eq("id", projectId)
|
||||||
|
.eq("user_id", user.id)
|
||||||
|
.select("*")
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = data as ProjectRow;
|
||||||
|
return NextResponse.json({
|
||||||
|
project: {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
type: row.type,
|
||||||
|
scene_data: row.scene_data,
|
||||||
|
status: row.status,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { buildMockProjectRow } from "@/lib/dev-mock-project";
|
||||||
|
import {
|
||||||
|
createDefaultSceneData,
|
||||||
|
defaultProjectName,
|
||||||
|
} from "@/lib/project-defaults";
|
||||||
|
import { mapProjectRow, type ProjectRow } from "@/lib/projects";
|
||||||
|
import { isSupabaseConfigured } from "@/lib/supabase/config";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const createProjectSchema = z.object({
|
||||||
|
name: z.string().min(1).max(120).optional(),
|
||||||
|
type: z.enum(["video", "image", "trimmer"]),
|
||||||
|
scene_data: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
if (!isSupabaseConfigured()) {
|
||||||
|
if (process.env.NODE_ENV === "production") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "Supabase is not configured",
|
||||||
|
code: "SUPABASE_NOT_CONFIGURED",
|
||||||
|
},
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ projects: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("projects")
|
||||||
|
.select("*")
|
||||||
|
.eq("user_id", user.id)
|
||||||
|
.order("updated_at", { ascending: false });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const projects = ((data ?? []) as ProjectRow[]).map(mapProjectRow);
|
||||||
|
return NextResponse.json({ projects });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = createProjectSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Validation failed", details: parsed.error.flatten() },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type } = parsed.data;
|
||||||
|
const name = parsed.data.name ?? defaultProjectName(type);
|
||||||
|
const scene_data =
|
||||||
|
parsed.data.scene_data ?? createDefaultSceneData(type);
|
||||||
|
|
||||||
|
if (!isSupabaseConfigured()) {
|
||||||
|
if (process.env.NODE_ENV === "production") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "Supabase is not configured",
|
||||||
|
code: "SUPABASE_NOT_CONFIGURED",
|
||||||
|
},
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = mapProjectRow(
|
||||||
|
buildMockProjectRow({ name, type, scene_data })
|
||||||
|
);
|
||||||
|
return NextResponse.json({ project }, { status: 201 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("projects")
|
||||||
|
.insert({
|
||||||
|
user_id: user.id,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
scene_data,
|
||||||
|
status: "draft",
|
||||||
|
})
|
||||||
|
.select("*")
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error?.message ?? "Failed to create project" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = mapProjectRow(data as ProjectRow);
|
||||||
|
return NextResponse.json({ project }, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
interface RemoveBgBody {
|
||||||
|
image?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const apiKey = process.env.REMOVE_BG_API_KEY;
|
||||||
|
const rembgUrl = process.env.REMBG_SERVICE_URL;
|
||||||
|
|
||||||
|
let body: RemoveBgBody;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as RemoveBgBody;
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.image?.startsWith("data:image")) {
|
||||||
|
return NextResponse.json({ error: "image data URL required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64 = body.image.split(",")[1];
|
||||||
|
if (!base64) {
|
||||||
|
return NextResponse.json({ error: "Invalid data URL" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(base64, "base64");
|
||||||
|
|
||||||
|
if (rembgUrl) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(rembgUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/octet-stream" },
|
||||||
|
body: buffer,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "rembg service failed" },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const resultBuffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
return NextResponse.json({
|
||||||
|
image: `data:image/png;base64,${resultBuffer.toString("base64")}`,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Could not reach rembg service" },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"Background removal not configured. Set REMOVE_BG_API_KEY or REMBG_SERVICE_URL.",
|
||||||
|
},
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append(
|
||||||
|
"image_file",
|
||||||
|
new Blob([buffer], { type: "image/png" }),
|
||||||
|
"upload.png"
|
||||||
|
);
|
||||||
|
formData.append("size", "auto");
|
||||||
|
|
||||||
|
const response = await fetch("https://api.remove.bg/v1.0/removebg", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "X-Api-Key": apiKey },
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "remove.bg API request failed" },
|
||||||
|
{ status: response.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultBuffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
return NextResponse.json({
|
||||||
|
image: `data:image/png;base64,${resultBuffer.toString("base64")}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { getRenderJob } from "@/lib/render-jobs";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
interface RouteContext {
|
||||||
|
params: { jobId: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(_request: Request, context: RouteContext) {
|
||||||
|
const { jobId } = context.params;
|
||||||
|
|
||||||
|
if (!jobId) {
|
||||||
|
return NextResponse.json({ error: "jobId required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = await getRenderJob(jobId);
|
||||||
|
if (!job) {
|
||||||
|
return NextResponse.json({ error: "Job not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
status: job.status,
|
||||||
|
progress: job.progress,
|
||||||
|
outputUrl: job.output_url,
|
||||||
|
progressMessage: job.progress_message,
|
||||||
|
errorMessage: job.error_message,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { renderRequestSchema } from "@/lib/render-schemas";
|
||||||
|
import { createRenderJob, triggerRenderWorker } from "@/lib/render-jobs";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = renderRequestSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Validation failed", details: parsed.error.flatten() },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await createRenderJob(parsed.data);
|
||||||
|
if ("error" in result) {
|
||||||
|
return NextResponse.json({ error: result.error }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await triggerRenderWorker(result.jobId);
|
||||||
|
|
||||||
|
return NextResponse.json({ jobId: result.jobId });
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type Stripe from "stripe";
|
||||||
|
|
||||||
|
import { isPaidPlanId, type PlanId } from "@/lib/plans";
|
||||||
|
import { getStripe } from "@/lib/stripe";
|
||||||
|
import { createAdminClient } from "@/lib/supabase/admin";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
function resolvePlanId(metadata: Stripe.Metadata | null): PlanId | null {
|
||||||
|
const planId = metadata?.planId;
|
||||||
|
if (planId && isPaidPlanId(planId)) {
|
||||||
|
return planId;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertProfileFromSession(session: Stripe.Checkout.Session) {
|
||||||
|
const userId = session.client_reference_id ?? session.metadata?.userId;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = resolvePlanId(session.metadata);
|
||||||
|
if (!plan) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = createAdminClient();
|
||||||
|
|
||||||
|
const { error } = await admin.from("profiles").upsert(
|
||||||
|
{
|
||||||
|
id: userId,
|
||||||
|
email: session.customer_email ?? session.customer_details?.email ?? null,
|
||||||
|
plan,
|
||||||
|
billing_period: session.metadata?.billingPeriod ?? null,
|
||||||
|
stripe_customer_id:
|
||||||
|
typeof session.customer === "string" ? session.customer : null,
|
||||||
|
stripe_subscription_id:
|
||||||
|
typeof session.subscription === "string"
|
||||||
|
? session.subscription
|
||||||
|
: null,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{ onConflict: "id" }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Failed to update profile: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||||
|
|
||||||
|
if (!webhookSecret) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Webhook secret not configured." },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const signature = request.headers.get("stripe-signature");
|
||||||
|
|
||||||
|
if (!signature) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing stripe-signature header." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.text();
|
||||||
|
const stripe = getStripe();
|
||||||
|
|
||||||
|
let event: Stripe.Event;
|
||||||
|
|
||||||
|
try {
|
||||||
|
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Webhook signature verification failed.";
|
||||||
|
return NextResponse.json({ error: message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (event.type) {
|
||||||
|
case "checkout.session.completed": {
|
||||||
|
const session = event.data.object as Stripe.Checkout.Session;
|
||||||
|
if (session.mode === "subscription") {
|
||||||
|
await upsertProfileFromSession(session);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "customer.subscription.deleted": {
|
||||||
|
const subscription = event.data.object as Stripe.Subscription;
|
||||||
|
const userId = subscription.metadata?.userId;
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
const admin = createAdminClient();
|
||||||
|
await admin
|
||||||
|
.from("profiles")
|
||||||
|
.update({
|
||||||
|
plan: "free",
|
||||||
|
billing_period: null,
|
||||||
|
stripe_subscription_id: null,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq("id", userId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Webhook handler failed.";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ received: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { isSupabaseConfigured } from "@/lib/supabase/config";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams, origin } = new URL(request.url);
|
||||||
|
const code = searchParams.get("code");
|
||||||
|
const next = searchParams.get("next") ?? "/dashboard";
|
||||||
|
|
||||||
|
if (code && isSupabaseConfigured()) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { error } = await supabase.auth.exchangeCodeForSession(code);
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
return NextResponse.redirect(`${origin}${next}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.redirect(`${origin}/auth?error=auth_callback_failed`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
await supabase.auth.signOut();
|
||||||
|
|
||||||
|
const { origin } = new URL(request.url);
|
||||||
|
return NextResponse.redirect(`${origin}/auth`, { status: 303 });
|
||||||
|
}
|
||||||
+66
-11
@@ -2,26 +2,81 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--background: 0 0% 100%;
|
||||||
--foreground: #171717;
|
--foreground: 0 0% 9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 0 0% 9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 0 0% 9%;
|
||||||
|
--primary: 221 83% 53%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 0 0% 96%;
|
||||||
|
--secondary-foreground: 0 0% 9%;
|
||||||
|
--muted: 0 0% 96%;
|
||||||
|
--muted-foreground: 0 0% 45%;
|
||||||
|
--accent: 0 0% 96%;
|
||||||
|
--accent-foreground: 0 0% 9%;
|
||||||
|
--destructive: 0 84% 60%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 0 0% 90%;
|
||||||
|
--input: 0 0% 90%;
|
||||||
|
--ring: 221 83% 53%;
|
||||||
|
--radius: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
.dark {
|
||||||
:root {
|
--background: 0 0% 4%;
|
||||||
--background: #0a0a0a;
|
--foreground: 0 0% 98%;
|
||||||
--foreground: #ededed;
|
--card: 0 0% 4%;
|
||||||
|
--card-foreground: 0 0% 98%;
|
||||||
|
--popover: 0 0% 4%;
|
||||||
|
--popover-foreground: 0 0% 98%;
|
||||||
|
--primary: 221 83% 53%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 0 0% 15%;
|
||||||
|
--secondary-foreground: 0 0% 98%;
|
||||||
|
--muted: 0 0% 15%;
|
||||||
|
--muted-foreground: 0 0% 64%;
|
||||||
|
--accent: 0 0% 15%;
|
||||||
|
--accent-foreground: 0 0% 98%;
|
||||||
|
--destructive: 0 62% 30%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 0 0% 15%;
|
||||||
|
--input: 0 0% 15%;
|
||||||
|
--ring: 221 83% 53%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
color: var(--foreground);
|
@apply bg-background text-foreground font-body antialiased;
|
||||||
background: var(--background);
|
}
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
@apply font-heading;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.text-balance {
|
.bg-checkerboard {
|
||||||
text-wrap: balance;
|
background-color: #1f2937;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(45deg, #374151 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, #374151 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, #374151 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, #374151 75%);
|
||||||
|
background-size: 20px 20px;
|
||||||
|
background-position: 0 0, 0 10px, 10px -10px, -10px 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-32
@@ -1,35 +1,10 @@
|
|||||||
import type { Metadata } from "next";
|
/**
|
||||||
import localFont from "next/font/local";
|
* Root layout — minimal pass-through.
|
||||||
import "./globals.css";
|
* The actual HTML structure (lang, dir, fonts) lives in [locale]/layout.tsx.
|
||||||
|
* This file exists only because Next.js requires a root layout.tsx.
|
||||||
const geistSans = localFont({
|
*/
|
||||||
src: "./fonts/GeistVF.woff",
|
|
||||||
variable: "--font-geist-sans",
|
|
||||||
weight: "100 900",
|
|
||||||
});
|
|
||||||
const geistMono = localFont({
|
|
||||||
src: "./fonts/GeistMonoVF.woff",
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
weight: "100 900",
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Create Next App",
|
|
||||||
description: "Generated by create next app",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
children: React.ReactNode;
|
return children;
|
||||||
}>) {
|
|
||||||
return (
|
|
||||||
<html lang="en">
|
|
||||||
<body
|
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { ImageResponse } from "next/og";
|
||||||
|
|
||||||
|
export const runtime = "edge";
|
||||||
|
|
||||||
|
export const alt = "CreatorStudio — AI Video & Image Maker";
|
||||||
|
export const size = { width: 1200, height: 630 };
|
||||||
|
export const contentType = "image/png";
|
||||||
|
|
||||||
|
export default function OpenGraphImage() {
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
width: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "linear-gradient(135deg, #1e40af 0%, #2563EB 50%, #7c3aed 100%)",
|
||||||
|
padding: "80px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "rgba(255,255,255,0.85)",
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
CreatorStudio
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 64,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "white",
|
||||||
|
lineHeight: 1.1,
|
||||||
|
maxWidth: 900,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create pro videos & images with AI
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 28,
|
||||||
|
color: "rgba(255,255,255,0.9)",
|
||||||
|
marginTop: 24,
|
||||||
|
maxWidth: 800,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Templates, editors, and one-click export for every channel
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{ ...size }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import Image from "next/image";
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
|
||||||
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="https://nextjs.org/icons/next.svg"
|
|
||||||
alt="Next.js logo"
|
|
||||||
width={180}
|
|
||||||
height={38}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
|
||||||
<li className="mb-2">
|
|
||||||
Get started by editing{" "}
|
|
||||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
|
|
||||||
src/app/page.tsx
|
|
||||||
</code>
|
|
||||||
.
|
|
||||||
</li>
|
|
||||||
<li>Save and see your changes instantly.</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="https://nextjs.org/icons/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
/>
|
|
||||||
Deploy now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Read our docs
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="https://nextjs.org/icons/file.svg"
|
|
||||||
alt="File icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Learn
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="https://nextjs.org/icons/window.svg"
|
|
||||||
alt="Window icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Examples
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="https://nextjs.org/icons/globe.svg"
|
|
||||||
alt="Globe icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Go to nextjs.org →
|
|
||||||
</a>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
const siteUrl =
|
||||||
|
process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
|
||||||
|
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
return {
|
||||||
|
rules: {
|
||||||
|
userAgent: "*",
|
||||||
|
allow: "/",
|
||||||
|
disallow: ["/dashboard", "/studio", "/api"],
|
||||||
|
},
|
||||||
|
sitemap: new URL("/sitemap.xml", siteUrl).toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
const siteUrl =
|
||||||
|
process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
|
||||||
|
|
||||||
|
const PUBLIC_ROUTES = [
|
||||||
|
"/",
|
||||||
|
"/video-maker",
|
||||||
|
"/image-maker",
|
||||||
|
"/templates",
|
||||||
|
"/pricing",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
|
const lastModified = new Date();
|
||||||
|
|
||||||
|
return PUBLIC_ROUTES.map((path) => ({
|
||||||
|
url: new URL(path, siteUrl).toString(),
|
||||||
|
lastModified,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface AuthLoadingSpinnerProps {
|
||||||
|
label?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthLoadingSpinner({
|
||||||
|
label = "Loading...",
|
||||||
|
className,
|
||||||
|
}: AuthLoadingSpinnerProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center justify-center gap-3 text-neutral-600",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary-600" aria-hidden />
|
||||||
|
<p className="text-sm font-medium">{label}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
import { AuthLoadingSpinner } from "@/components/auth/AuthLoadingSpinner";
|
||||||
|
import { SupabaseSetupNotice } from "@/components/auth/SupabaseSetupNotice";
|
||||||
|
import { authFormSchema, type AuthFormValues } from "@/components/auth/auth-schemas";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { createClient } from "@/lib/supabase";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type AuthTab = "sign-in" | "sign-up";
|
||||||
|
|
||||||
|
export function AuthPageContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const supabase = useMemo(() => createClient(), []);
|
||||||
|
|
||||||
|
const initialTab =
|
||||||
|
searchParams.get("tab") === "sign-up" ? "sign-up" : "sign-in";
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<AuthTab>(initialTab);
|
||||||
|
const [authLoading, setAuthLoading] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [oauthLoading, setOauthLoading] = useState(false);
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [formMessage, setFormMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
reset,
|
||||||
|
} = useForm<AuthFormValues>({
|
||||||
|
resolver: zodResolver(authFormSchema),
|
||||||
|
defaultValues: { email: "", password: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const redirectIfAuthenticated = useCallback(async () => {
|
||||||
|
if (!supabase) return false;
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { session },
|
||||||
|
} = await supabase.auth.getSession();
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
router.replace("/dashboard");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}, [router, supabase]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!supabase) {
|
||||||
|
setAuthLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
const redirected = await redirectIfAuthenticated();
|
||||||
|
if (mounted && !redirected) {
|
||||||
|
setAuthLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { subscription },
|
||||||
|
} = supabase.auth.onAuthStateChange((_event, session) => {
|
||||||
|
if (session) {
|
||||||
|
router.replace("/dashboard");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
subscription.unsubscribe();
|
||||||
|
};
|
||||||
|
}, [redirectIfAuthenticated, router, supabase]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const error = searchParams.get("error");
|
||||||
|
if (error === "auth_callback_failed") {
|
||||||
|
setFormError("Authentication failed. Please try again.");
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const handleTabChange = (tab: AuthTab) => {
|
||||||
|
setActiveTab(tab);
|
||||||
|
setFormError(null);
|
||||||
|
setFormMessage(null);
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (values: AuthFormValues) => {
|
||||||
|
if (!supabase) return;
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
setFormError(null);
|
||||||
|
setFormMessage(null);
|
||||||
|
|
||||||
|
if (activeTab === "sign-in") {
|
||||||
|
const { error } = await supabase.auth.signInWithPassword({
|
||||||
|
email: values.email,
|
||||||
|
password: values.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setFormError(error.message);
|
||||||
|
setSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace("/dashboard");
|
||||||
|
} else {
|
||||||
|
const { data, error } = await supabase.auth.signUp({
|
||||||
|
email: values.email,
|
||||||
|
password: values.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setFormError(error.message);
|
||||||
|
setSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.session) {
|
||||||
|
router.replace("/dashboard");
|
||||||
|
} else {
|
||||||
|
setFormMessage(
|
||||||
|
"Check your email to confirm your account, then sign in."
|
||||||
|
);
|
||||||
|
setActiveTab("sign-in");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoogleSignIn = async () => {
|
||||||
|
if (!supabase) return;
|
||||||
|
|
||||||
|
setOauthLoading(true);
|
||||||
|
setFormError(null);
|
||||||
|
|
||||||
|
const { error } = await supabase.auth.signInWithOAuth({
|
||||||
|
provider: "google",
|
||||||
|
options: {
|
||||||
|
redirectTo: `${window.location.origin}/auth/callback?next=/dashboard`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setFormError(error.message);
|
||||||
|
setOauthLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (authLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center py-20">
|
||||||
|
<AuthLoadingSpinner label="Checking authentication..." />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!supabase) {
|
||||||
|
return (
|
||||||
|
<SupabaseSetupNotice nextPath={searchParams.get("next")} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBusy = submitting || oauthLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-md px-4 py-12 sm:py-16">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="font-heading text-3xl font-bold text-neutral-900">
|
||||||
|
Welcome to CreatorStudio
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-neutral-600">
|
||||||
|
{activeTab === "sign-in"
|
||||||
|
? "Sign in to continue to your dashboard"
|
||||||
|
: "Create a free account to get started"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 flex rounded-lg border border-gray-100 bg-neutral-50 p-1">
|
||||||
|
{(["sign-in", "sign-up"] as const).map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleTabChange(tab)}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2",
|
||||||
|
activeTab === tab
|
||||||
|
? "bg-white text-neutral-900 shadow-sm"
|
||||||
|
: "text-neutral-600 hover:text-neutral-900"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab === "sign-in" ? "Sign In" : "Sign Up"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 rounded-xl border border-gray-100 bg-white p-6 shadow-sm">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={handleGoogleSignIn}
|
||||||
|
>
|
||||||
|
{oauthLoading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||||
|
) : null}
|
||||||
|
Continue with Google
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="relative my-6">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<span className="w-full border-t border-gray-100" />
|
||||||
|
</div>
|
||||||
|
<p className="relative mx-auto w-fit bg-white px-3 text-xs text-neutral-500">
|
||||||
|
or continue with email
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="email"
|
||||||
|
className="block text-sm font-medium text-neutral-700"
|
||||||
|
>
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
disabled={isBusy}
|
||||||
|
className={cn(
|
||||||
|
"mt-1.5 w-full rounded-lg border bg-white px-3 py-2.5 text-sm text-neutral-900 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2 disabled:opacity-50",
|
||||||
|
errors.email ? "border-red-300" : "border-gray-100"
|
||||||
|
)}
|
||||||
|
{...register("email")}
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="mt-1.5 text-xs text-red-600">{errors.email.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="block text-sm font-medium text-neutral-700"
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete={
|
||||||
|
activeTab === "sign-in" ? "current-password" : "new-password"
|
||||||
|
}
|
||||||
|
disabled={isBusy}
|
||||||
|
className={cn(
|
||||||
|
"mt-1.5 w-full rounded-lg border bg-white px-3 py-2.5 text-sm text-neutral-900 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2 disabled:opacity-50",
|
||||||
|
errors.password ? "border-red-300" : "border-gray-100"
|
||||||
|
)}
|
||||||
|
{...register("password")}
|
||||||
|
/>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="mt-1.5 text-xs text-red-600">
|
||||||
|
{errors.password.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formError && (
|
||||||
|
<p className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
|
{formError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formMessage && (
|
||||||
|
<p className="rounded-lg bg-primary-50 px-3 py-2 text-sm text-primary-700">
|
||||||
|
{formMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={isBusy}>
|
||||||
|
{submitting ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||||
|
) : null}
|
||||||
|
{activeTab === "sign-in" ? "Sign In" : "Create Account"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-6 text-center text-xs text-neutral-500">
|
||||||
|
By continuing, you agree to our{" "}
|
||||||
|
<Link href="/terms" className="text-primary-600 hover:underline">
|
||||||
|
Terms
|
||||||
|
</Link>{" "}
|
||||||
|
and{" "}
|
||||||
|
<Link href="/privacy" className="text-primary-600 hover:underline">
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
interface SupabaseSetupNoticeProps {
|
||||||
|
nextPath: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SupabaseSetupNotice({ nextPath }: SupabaseSetupNoticeProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const isDev = process.env.NODE_ENV === "development";
|
||||||
|
const continueHref = nextPath?.startsWith("/") ? nextPath : "/dashboard";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-md px-4 py-12 sm:py-16">
|
||||||
|
<div className="rounded-xl border border-amber-200 bg-amber-50 p-6 text-center shadow-sm">
|
||||||
|
<h1 className="font-heading text-xl font-bold text-neutral-900">
|
||||||
|
Supabase not configured
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 text-sm text-neutral-600">
|
||||||
|
Copy <code className="rounded bg-white px-1.5 py-0.5 text-xs">.env.example</code>{" "}
|
||||||
|
to <code className="rounded bg-white px-1.5 py-0.5 text-xs">.env.local</code> and set{" "}
|
||||||
|
<code className="rounded bg-white px-1.5 py-0.5 text-xs">NEXT_PUBLIC_SUPABASE_URL</code>{" "}
|
||||||
|
and{" "}
|
||||||
|
<code className="rounded bg-white px-1.5 py-0.5 text-xs">NEXT_PUBLIC_SUPABASE_ANON_KEY</code>
|
||||||
|
, then restart the dev server.
|
||||||
|
</p>
|
||||||
|
{isDev ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="mt-6 w-full"
|
||||||
|
onClick={() => router.push(continueHref)}
|
||||||
|
>
|
||||||
|
Continue without signing in (dev only)
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button type="button" className="mt-6 w-full" asChild>
|
||||||
|
<Link href="/">Back to home</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const authFormSchema = z.object({
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Email is required")
|
||||||
|
.email("Enter a valid email address"),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(8, "Password must be at least 8 characters")
|
||||||
|
.max(72, "Password must be at most 72 characters"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AuthFormValues = z.infer<typeof authFormSchema>;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FolderOpen } from "lucide-react";
|
||||||
|
|
||||||
|
import { NewProjectMenu } from "@/components/dashboard/NewProjectMenu";
|
||||||
|
|
||||||
|
export function DashboardEmptyState() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-gray-200 bg-neutral-50 px-6 py-20 text-center">
|
||||||
|
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-primary-50 text-primary-600">
|
||||||
|
<FolderOpen className="h-10 w-10" aria-hidden />
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-6 font-heading text-xl font-semibold text-neutral-900">
|
||||||
|
No projects yet
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 max-w-sm text-sm text-neutral-600">
|
||||||
|
Create a video, image, or trim project to see it here. Everything you
|
||||||
|
save appears in this workspace.
|
||||||
|
</p>
|
||||||
|
<NewProjectMenu
|
||||||
|
triggerLabel="Create your first project"
|
||||||
|
triggerClassName="mt-8 gap-2"
|
||||||
|
align="center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { getUserProfile } from "@/lib/profiles";
|
||||||
|
import { getPlanLabel, type PlanId } from "@/lib/plans";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const planBadgeStyles: Record<PlanId, string> = {
|
||||||
|
free: "bg-neutral-100 text-neutral-700",
|
||||||
|
pro: "bg-primary-100 text-primary-700",
|
||||||
|
business: "bg-violet-100 text-violet-700",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DashboardPlanBadgeProps {
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DashboardPlanBadge({ userId }: DashboardPlanBadgeProps) {
|
||||||
|
const profile = await getUserProfile(userId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"mt-1 inline-flex rounded-full px-2.5 py-0.5 text-xs font-semibold",
|
||||||
|
planBadgeStyles[profile.plan]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getPlanLabel(profile.plan)}
|
||||||
|
</p>
|
||||||
|
{profile.plan !== "business" ? (
|
||||||
|
<Button size="sm" className="mt-3 w-full" asChild>
|
||||||
|
<Link href="/#pricing">Upgrade plan</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardPlanBadgeSkeleton() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="mt-1 h-5 w-20 animate-pulse rounded-full bg-gray-200"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { DashboardProjectsSection } from "@/components/dashboard/DashboardProjectsSection";
|
||||||
|
import { mapProjectRow, type ProjectRow } from "@/lib/projects";
|
||||||
|
import { isSupabaseConfigured } from "@/lib/supabase/config";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
|
export async function DashboardProjectsContent() {
|
||||||
|
let projects: ReturnType<typeof mapProjectRow>[] = [];
|
||||||
|
|
||||||
|
if (isSupabaseConfigured()) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
const { data } = user
|
||||||
|
? await supabase
|
||||||
|
.from("projects")
|
||||||
|
.select("*")
|
||||||
|
.eq("user_id", user.id)
|
||||||
|
.order("updated_at", { ascending: false })
|
||||||
|
: { data: [] };
|
||||||
|
|
||||||
|
projects = ((data ?? []) as ProjectRow[]).map(mapProjectRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DashboardProjectsSection projects={projects} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { DashboardEmptyState } from "@/components/dashboard/DashboardEmptyState";
|
||||||
|
import { DashboardTopBar } from "@/components/dashboard/DashboardTopBar";
|
||||||
|
import { ProjectCard } from "@/components/dashboard/ProjectCard";
|
||||||
|
import { SkeletonProjectCard } from "@/components/dashboard/SkeletonProjectCard";
|
||||||
|
import type { DashboardProject } from "@/lib/projects";
|
||||||
|
|
||||||
|
const SKELETON_CARD_COUNT = 6;
|
||||||
|
|
||||||
|
interface DashboardProjectsSectionProps {
|
||||||
|
projects?: DashboardProject[];
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardProjectsSection({
|
||||||
|
projects = [],
|
||||||
|
isLoading = false,
|
||||||
|
}: DashboardProjectsSectionProps) {
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
|
const filteredProjects = useMemo(() => {
|
||||||
|
const query = searchQuery.trim().toLowerCase();
|
||||||
|
if (!query) return projects;
|
||||||
|
return projects.filter((project) =>
|
||||||
|
project.name.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}, [projects, searchQuery]);
|
||||||
|
|
||||||
|
const showEmpty = !isLoading && projects.length === 0;
|
||||||
|
const showNoResults =
|
||||||
|
!isLoading && !showEmpty && filteredProjects.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<DashboardTopBar
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSearchChange={setSearchQuery}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto p-6">
|
||||||
|
<h2 className="font-heading text-xl font-bold text-neutral-900">
|
||||||
|
Recent Projects
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{showEmpty && (
|
||||||
|
<div className="mt-8">
|
||||||
|
<DashboardEmptyState />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="mt-6 grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{Array.from({ length: SKELETON_CARD_COUNT }, (_, index) => (
|
||||||
|
<SkeletonProjectCard key={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showNoResults && (
|
||||||
|
<div className="mt-8 rounded-xl border border-dashed border-gray-200 bg-neutral-50 px-6 py-12 text-center">
|
||||||
|
<p className="font-heading text-lg font-semibold text-neutral-900">
|
||||||
|
No projects match your search
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-neutral-600">
|
||||||
|
Try a different keyword or clear the search bar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !showEmpty && filteredProjects.length > 0 && (
|
||||||
|
<div className="mt-6 grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{filteredProjects.map((project) => (
|
||||||
|
<ProjectCard key={project.id} project={project} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { DashboardSidebar } from "@/components/dashboard/DashboardSidebar";
|
||||||
|
|
||||||
|
interface DashboardShellProps {
|
||||||
|
userEmail: string;
|
||||||
|
userName?: string | null;
|
||||||
|
userId: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardShell({
|
||||||
|
userEmail,
|
||||||
|
userName,
|
||||||
|
userId,
|
||||||
|
children,
|
||||||
|
}: DashboardShellProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-neutral-50">
|
||||||
|
<DashboardSidebar
|
||||||
|
userEmail={userEmail}
|
||||||
|
userName={userName}
|
||||||
|
userId={userId}
|
||||||
|
/>
|
||||||
|
<div className="flex min-h-screen min-w-0 flex-1 flex-col">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { Sparkles } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DashboardPlanBadge,
|
||||||
|
DashboardPlanBadgeSkeleton,
|
||||||
|
} from "@/components/dashboard/DashboardPlanBadge";
|
||||||
|
import { DashboardSidebarNav } from "@/components/dashboard/DashboardSidebarNav";
|
||||||
|
|
||||||
|
interface DashboardSidebarProps {
|
||||||
|
userEmail: string;
|
||||||
|
userName?: string | null;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitials(email: string, name?: string | null): string {
|
||||||
|
if (name?.trim()) {
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
return parts
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0]?.toUpperCase() ?? "")
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return email.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardSidebar({
|
||||||
|
userEmail,
|
||||||
|
userName,
|
||||||
|
userId,
|
||||||
|
}: DashboardSidebarProps) {
|
||||||
|
const initials = getInitials(userEmail, userName);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="flex h-full w-60 shrink-0 flex-col border-r border-gray-100 bg-white">
|
||||||
|
<div className="border-b border-gray-100 px-4 py-5">
|
||||||
|
<Link
|
||||||
|
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>
|
||||||
|
<span className="font-heading text-lg font-bold text-neutral-900">
|
||||||
|
FlatRender
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DashboardSidebarNav />
|
||||||
|
|
||||||
|
<div className="border-t border-gray-100 p-4">
|
||||||
|
<div className="mb-3 rounded-lg border border-gray-100 bg-neutral-50 p-3">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-neutral-500">
|
||||||
|
Current plan
|
||||||
|
</p>
|
||||||
|
<Suspense fallback={<DashboardPlanBadgeSkeleton />}>
|
||||||
|
<DashboardPlanBadge userId={userId} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 rounded-lg px-2 py-2">
|
||||||
|
<div
|
||||||
|
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary-100 font-heading text-sm font-semibold text-primary-700"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium text-neutral-900">
|
||||||
|
{userName ?? userEmail.split("@")[0]}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-neutral-500">{userEmail}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form action="/auth/sign-out" method="post" className="mt-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full rounded-lg px-3 py-2 text-left text-sm text-neutral-600 transition-colors hover:bg-neutral-50 hover:text-neutral-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import {
|
||||||
|
FolderOpen,
|
||||||
|
LayoutTemplate,
|
||||||
|
Settings,
|
||||||
|
Zap,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ label: "My Projects", href: "/dashboard", icon: FolderOpen },
|
||||||
|
{ label: "Templates", href: "/templates", icon: LayoutTemplate },
|
||||||
|
{ label: "Upgrade", href: "/#pricing", icon: Zap },
|
||||||
|
{ label: "Settings", href: "/dashboard/settings", icon: Settings },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function DashboardSidebarNav() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="flex-1 space-y-1 px-3 py-4" aria-label="Dashboard">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive =
|
||||||
|
item.href === "/dashboard"
|
||||||
|
? pathname === "/dashboard"
|
||||||
|
: pathname.startsWith(item.href);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.label}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2",
|
||||||
|
isActive
|
||||||
|
? "bg-primary-50 text-primary-700"
|
||||||
|
: "text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 shrink-0" aria-hidden />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
|
||||||
|
import { NewProjectMenu } from "@/components/dashboard/NewProjectMenu";
|
||||||
|
|
||||||
|
interface DashboardTopBarProps {
|
||||||
|
searchQuery: string;
|
||||||
|
onSearchChange: (query: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardTopBar({
|
||||||
|
searchQuery,
|
||||||
|
onSearchChange,
|
||||||
|
}: DashboardTopBarProps) {
|
||||||
|
return (
|
||||||
|
<header className="flex flex-col gap-4 border-b border-gray-100 bg-white px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<label className="relative max-w-md flex-1">
|
||||||
|
<Search
|
||||||
|
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-neutral-400"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(event) => onSearchChange(event.target.value)}
|
||||||
|
placeholder="Search projects..."
|
||||||
|
className="w-full rounded-lg border border-gray-100 bg-neutral-50 py-2.5 pl-10 pr-4 text-sm text-neutral-900 placeholder:text-neutral-400 focus-visible:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<NewProjectMenu triggerClassName="shrink-0 gap-2" />
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ChevronDown, Clapperboard, ImageIcon, Plus, Scissors } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import type { ProjectType } from "@/lib/projects";
|
||||||
|
|
||||||
|
interface NewProjectMenuProps {
|
||||||
|
triggerLabel?: string;
|
||||||
|
triggerClassName?: string;
|
||||||
|
align?: "start" | "center" | "end";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewProjectMenu({
|
||||||
|
triggerLabel = "New Project",
|
||||||
|
triggerClassName,
|
||||||
|
align = "end",
|
||||||
|
}: NewProjectMenuProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
|
||||||
|
const createProject = async (type: ProjectType) => {
|
||||||
|
setIsCreating(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/projects", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ type }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = (await response.json()) as {
|
||||||
|
project?: { id: string; type: ProjectType };
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!response.ok || !data.project) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.project.type === "video") {
|
||||||
|
router.push(`/studio/video/${data.project.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.project.type === "image") {
|
||||||
|
router.push(`/studio/image/${data.project.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push("/studio/trimmer");
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button className={triggerClassName} disabled={isCreating}>
|
||||||
|
<Plus className="h-4 w-4" aria-hidden />
|
||||||
|
{isCreating ? "Creating…" : triggerLabel}
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-80" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align={align} className="w-56">
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer gap-2"
|
||||||
|
onClick={() => router.push("/studio/video/new")}
|
||||||
|
>
|
||||||
|
<Clapperboard className="h-4 w-4 text-primary-600" />
|
||||||
|
Video Project
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer gap-2"
|
||||||
|
onClick={() => createProject("image")}
|
||||||
|
>
|
||||||
|
<ImageIcon className="h-4 w-4 text-violet-600" />
|
||||||
|
Image Project
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer gap-2"
|
||||||
|
onClick={() => createProject("trimmer")}
|
||||||
|
>
|
||||||
|
<Scissors className="h-4 w-4 text-amber-600" />
|
||||||
|
Trim/Crop Video
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { Copy, Download, ExternalLink, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { OptimizedImage } from "@/components/ui/optimized-image";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { getTemplatePreviewVideoSrc } from "@/lib/template-preview-media";
|
||||||
|
import {
|
||||||
|
formatLastEdited,
|
||||||
|
getProjectStudioPath,
|
||||||
|
getProjectThumbnailSrc,
|
||||||
|
getProjectTypeLabel,
|
||||||
|
type DashboardProject,
|
||||||
|
} from "@/lib/projects";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ProjectCardProps {
|
||||||
|
project: DashboardProject;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadgeClass(status: DashboardProject["status"]): string {
|
||||||
|
switch (status) {
|
||||||
|
case "rendering":
|
||||||
|
return "bg-amber-100 text-amber-800";
|
||||||
|
case "ready":
|
||||||
|
return "bg-green-100 text-green-800";
|
||||||
|
default:
|
||||||
|
return "bg-neutral-100 text-neutral-600";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: DashboardProject["status"]): string {
|
||||||
|
switch (status) {
|
||||||
|
case "rendering":
|
||||||
|
return "Rendering";
|
||||||
|
case "ready":
|
||||||
|
return "Ready";
|
||||||
|
default:
|
||||||
|
return "Draft";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeBadgeClass(type: DashboardProject["type"]): string {
|
||||||
|
switch (type) {
|
||||||
|
case "video":
|
||||||
|
return "bg-primary-100 text-primary-700";
|
||||||
|
case "image":
|
||||||
|
return "bg-violet-100 text-violet-700";
|
||||||
|
case "trimmer":
|
||||||
|
return "bg-amber-100 text-amber-800";
|
||||||
|
default:
|
||||||
|
return "bg-neutral-100 text-neutral-600";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fadeTransition = { duration: 0.25, ease: "easeOut" as const };
|
||||||
|
|
||||||
|
export function ProjectCard({ project }: ProjectCardProps) {
|
||||||
|
const studioPath = getProjectStudioPath(project);
|
||||||
|
const showRenderStatus = project.type === "video";
|
||||||
|
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const handleMouseEnter = useCallback(() => setIsHovered(true), []);
|
||||||
|
const handleMouseLeave = useCallback(() => setIsHovered(false), []);
|
||||||
|
|
||||||
|
// For ready projects use their render; for others use a stock preview clip
|
||||||
|
const previewVideoSrc =
|
||||||
|
project.status === "ready" && project.renderUrl
|
||||||
|
? project.renderUrl
|
||||||
|
: project.type === "video"
|
||||||
|
? getTemplatePreviewVideoSrc(project.id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className="group overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm transition-shadow hover:shadow-md"
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
<div className="relative aspect-video overflow-hidden bg-neutral-100">
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<OptimizedImage
|
||||||
|
src={getProjectThumbnailSrc(project.thumbnailSeed)}
|
||||||
|
alt={project.name}
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||||
|
className="object-cover transition-transform duration-300 ease-out group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Hover video preview (video projects only) */}
|
||||||
|
{previewVideoSrc ? (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isHovered ? (
|
||||||
|
<motion.div
|
||||||
|
key="preview-video"
|
||||||
|
className="pointer-events-none absolute inset-0"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={fadeTransition}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
src={previewVideoSrc}
|
||||||
|
autoPlay
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
|
aria-hidden
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Action overlay */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/50 opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100">
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
size="sm"
|
||||||
|
className="bg-white text-neutral-900 hover:bg-neutral-100"
|
||||||
|
>
|
||||||
|
<Link href={studioPath}>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
Open in Studio
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
{project.status === "ready" && project.renderUrl ? (
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-white/80 bg-transparent text-white hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<a href={project.renderUrl} download>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between gap-2 p-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="truncate font-heading text-sm font-semibold text-neutral-900">
|
||||||
|
{project.name}
|
||||||
|
</h3>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded px-2 py-0.5 text-[10px] font-bold tracking-wide",
|
||||||
|
typeBadgeClass(project.type)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getProjectTypeLabel(project.type)}
|
||||||
|
</span>
|
||||||
|
{showRenderStatus ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-2.5 py-0.5 text-xs font-medium",
|
||||||
|
statusBadgeClass(project.status)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{statusLabel(project.status)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="text-xs text-neutral-500">
|
||||||
|
{formatLastEdited(project.lastEditedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2"
|
||||||
|
aria-label={`Actions for ${project.name}`}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={studioPath} className="gap-2">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
Open in Studio
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{project.renderUrl ? (
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<a href={project.renderUrl} download className="gap-2">
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem className="gap-2">
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
Rename
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="gap-2">
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
Duplicate
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem className="gap-2 text-red-600 focus:text-red-600">
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export function SkeletonProjectCard() {
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className="overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<div className="aspect-video animate-pulse bg-gray-200" />
|
||||||
|
<div className="flex items-start justify-between gap-2 p-4">
|
||||||
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
<div className="h-4 w-3/4 max-w-[180px] animate-pulse rounded bg-gray-200" />
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="h-5 w-14 animate-pulse rounded bg-gray-200" />
|
||||||
|
<div className="h-5 w-16 animate-pulse rounded-full bg-gray-200" />
|
||||||
|
<div className="h-4 w-20 animate-pulse rounded bg-gray-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-8 w-8 shrink-0 animate-pulse rounded-lg bg-gray-200" />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Loader2, Sparkles } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { toast } from "@/components/ui/use-toast";
|
||||||
|
import { getImageEditorStage } from "@/lib/image-editor-stage-ref";
|
||||||
|
import {
|
||||||
|
getBaseImageLayer,
|
||||||
|
useImageEditorStore,
|
||||||
|
} from "@/lib/image-editor-store";
|
||||||
|
|
||||||
|
export function AiRemoveBgModal() {
|
||||||
|
const isOpen = useImageEditorStore((s) => s.isAiModalOpen);
|
||||||
|
const setAiModalOpen = useImageEditorStore((s) => s.setAiModalOpen);
|
||||||
|
const replaceBaseImage = useImageEditorStore((s) => s.replaceBaseImage);
|
||||||
|
const layers = useImageEditorStore((s) => s.layers);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleRemoveBg = async () => {
|
||||||
|
const stage = getImageEditorStage();
|
||||||
|
const base = getBaseImageLayer({ layers });
|
||||||
|
if (!stage || !base) {
|
||||||
|
toast({ title: "Open an image first." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataUrl = stage.toDataURL({ pixelRatio: 1 });
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/remove-bg", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ image: dataUrl }),
|
||||||
|
});
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
image?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!response.ok || !payload.image) {
|
||||||
|
toast({ title: payload.error ?? "Background removal failed." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceBaseImage(payload.image);
|
||||||
|
toast({ title: "Background removed!" });
|
||||||
|
setAiModalOpen(false);
|
||||||
|
} catch {
|
||||||
|
toast({ title: "Could not reach background removal service." });
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setAiModalOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-5 w-5 text-primary-400" />
|
||||||
|
AI Background Removal
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Remove the background from your base image. The result replaces the
|
||||||
|
background layer with a transparent PNG.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full bg-primary-600 hover:bg-primary-700"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={handleRemoveBg}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Processing…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Remove Background"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { ImageCropAspectRatio } from "@/lib/image-editor-types";
|
||||||
|
import { useImageEditorStore } from "@/lib/image-editor-store";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const ASPECT_OPTIONS: { id: ImageCropAspectRatio; label: string }[] = [
|
||||||
|
{ id: "free", label: "Free" },
|
||||||
|
{ id: "1:1", label: "1:1" },
|
||||||
|
{ id: "16:9", label: "16:9" },
|
||||||
|
{ id: "4:3", label: "4:3" },
|
||||||
|
{ id: "9:16", label: "9:16" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ImageCropControls() {
|
||||||
|
const [applying, setApplying] = useState(false);
|
||||||
|
const activeTool = useImageEditorStore((s) => s.activeTool);
|
||||||
|
const cropAspectRatio = useImageEditorStore((s) => s.cropAspectRatio);
|
||||||
|
const setCropAspectRatio = useImageEditorStore((s) => s.setCropAspectRatio);
|
||||||
|
const applyCrop = useImageEditorStore((s) => s.applyCrop);
|
||||||
|
const cancelCrop = useImageEditorStore((s) => s.cancelCrop);
|
||||||
|
|
||||||
|
if (activeTool !== "crop") return null;
|
||||||
|
|
||||||
|
const handleApply = async () => {
|
||||||
|
setApplying(true);
|
||||||
|
try {
|
||||||
|
await applyCrop();
|
||||||
|
} finally {
|
||||||
|
setApplying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center justify-center gap-3 border-b border-gray-800 bg-gray-900 px-4 py-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{ASPECT_OPTIONS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCropAspectRatio(option.id)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500",
|
||||||
|
cropAspectRatio === option.id
|
||||||
|
? "border-violet-600 bg-violet-600 text-white"
|
||||||
|
: "border-gray-700 bg-gray-800 text-gray-200 hover:border-gray-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="border-gray-700 bg-gray-800 text-gray-200"
|
||||||
|
onClick={cancelCrop}
|
||||||
|
disabled={applying}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="bg-violet-600 hover:bg-violet-700"
|
||||||
|
onClick={() => void handleApply()}
|
||||||
|
disabled={applying}
|
||||||
|
>
|
||||||
|
{applying ? "Applying…" : "Apply Crop"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { AiRemoveBgModal } from "@/components/image-editor/AiRemoveBgModal";
|
||||||
|
import { ImageCropControls } from "@/components/image-editor/ImageCropControls";
|
||||||
|
import { ImageEditorRightPanel } from "@/components/image-editor/ImageEditorRightPanel";
|
||||||
|
import { ImageEditorToolbar } from "@/components/image-editor/ImageEditorToolbar";
|
||||||
|
import { ImageEditorTopBar } from "@/components/image-editor/ImageEditorTopBar";
|
||||||
|
import { StudioMobileGate } from "@/components/studio/StudioMobileGate";
|
||||||
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
|
import { useImageProjectPersistence } from "@/hooks/useImageProjectPersistence";
|
||||||
|
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||||
|
|
||||||
|
const ImageEditorCanvas = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/components/image-editor/canvas/ImageEditorCanvas").then(
|
||||||
|
(mod) => mod.ImageEditorCanvas
|
||||||
|
),
|
||||||
|
{ ssr: false, loading: () => <div className="h-full w-full bg-gray-950" /> }
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ImageEditorLayoutProps {
|
||||||
|
projectId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageEditorLayout({ projectId }: ImageEditorLayoutProps) {
|
||||||
|
const { isMobile, isReady } = useIsMobile();
|
||||||
|
const { projectName, saveStatus, retrySave } =
|
||||||
|
useImageProjectPersistence(projectId);
|
||||||
|
|
||||||
|
if (!isReady) {
|
||||||
|
return <div className="h-screen w-screen bg-gray-950" aria-hidden />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return <StudioMobileGate variant="image" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-screen flex-col overflow-hidden bg-gray-950 text-white">
|
||||||
|
<Toaster />
|
||||||
|
<ImageEditorTopBar
|
||||||
|
projectId={projectId}
|
||||||
|
projectName={projectName}
|
||||||
|
saveStatus={saveStatus}
|
||||||
|
onSaveRetry={retrySave}
|
||||||
|
/>
|
||||||
|
<div className="flex min-h-0 flex-1">
|
||||||
|
<ImageEditorToolbar />
|
||||||
|
<main className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<ImageCropControls />
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
<ImageEditorCanvas />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<ImageEditorRightPanel />
|
||||||
|
</div>
|
||||||
|
<AiRemoveBgModal />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AdjustPanel } from "@/components/image-editor/panels/AdjustPanel";
|
||||||
|
import { FiltersPanel } from "@/components/image-editor/panels/FiltersPanel";
|
||||||
|
import { LayersPanel } from "@/components/image-editor/panels/LayersPanel";
|
||||||
|
import type { ImagePanelTab } from "@/lib/image-editor-types";
|
||||||
|
import { useImageEditorStore } from "@/lib/image-editor-store";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const TABS: { id: ImagePanelTab; label: string }[] = [
|
||||||
|
{ id: "adjust", label: "Adjust" },
|
||||||
|
{ id: "filters", label: "Filters" },
|
||||||
|
{ id: "layers", label: "Layers" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ImageEditorRightPanel() {
|
||||||
|
const activePanelTab = useImageEditorStore((s) => s.activePanelTab);
|
||||||
|
const setActivePanelTab = useImageEditorStore((s) => s.setActivePanelTab);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="flex w-[280px] shrink-0 flex-col border-l border-gray-800 bg-gray-900">
|
||||||
|
<div className="flex border-b border-gray-800">
|
||||||
|
{TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActivePanelTab(tab.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 px-2 py-3 text-xs font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary-500",
|
||||||
|
activePanelTab === tab.id
|
||||||
|
? "border-b-2 border-primary-500 text-white"
|
||||||
|
: "text-gray-500 hover:text-gray-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
{activePanelTab === "adjust" ? <AdjustPanel /> : null}
|
||||||
|
{activePanelTab === "filters" ? <FiltersPanel /> : null}
|
||||||
|
{activePanelTab === "layers" ? <LayersPanel /> : null}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Crop,
|
||||||
|
MousePointer2,
|
||||||
|
Pencil,
|
||||||
|
Shapes,
|
||||||
|
Sparkles,
|
||||||
|
Type,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import type { ImageShapeKind, ImageTool } from "@/lib/image-editor-types";
|
||||||
|
import { useImageEditorStore } from "@/lib/image-editor-store";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const TOOLS: { id: ImageTool; label: string; icon: typeof MousePointer2 }[] = [
|
||||||
|
{ id: "select", label: "Select", icon: MousePointer2 },
|
||||||
|
{ id: "crop", label: "Crop", icon: Crop },
|
||||||
|
{ id: "text", label: "Text", icon: Type },
|
||||||
|
{ id: "shape", label: "Shape", icon: Shapes },
|
||||||
|
{ id: "draw", label: "Draw", icon: Pencil },
|
||||||
|
{ id: "ai", label: "AI", icon: Sparkles },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SHAPES: { id: ImageShapeKind; label: string }[] = [
|
||||||
|
{ id: "rect", label: "Rectangle" },
|
||||||
|
{ id: "circle", label: "Circle" },
|
||||||
|
{ id: "line", label: "Line" },
|
||||||
|
{ id: "arrow", label: "Arrow" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ImageEditorToolbar() {
|
||||||
|
const [shapeOpen, setShapeOpen] = useState(false);
|
||||||
|
const activeTool = useImageEditorStore((s) => s.activeTool);
|
||||||
|
const setActiveTool = useImageEditorStore((s) => s.setActiveTool);
|
||||||
|
const setPendingShape = useImageEditorStore((s) => s.setPendingShape);
|
||||||
|
const setAiModalOpen = useImageEditorStore((s) => s.setAiModalOpen);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="flex w-14 shrink-0 flex-col items-center gap-1 border-r border-gray-800 bg-gray-900 py-3">
|
||||||
|
{TOOLS.map((tool) => {
|
||||||
|
const Icon = tool.icon;
|
||||||
|
if (tool.id === "shape") {
|
||||||
|
return (
|
||||||
|
<Popover key={tool.id} open={shapeOpen} onOpenChange={setShapeOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={tool.label}
|
||||||
|
onClick={() => setActiveTool("shape")}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-10 items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500",
|
||||||
|
activeTool === "shape"
|
||||||
|
? "bg-primary-600 text-white"
|
||||||
|
: "text-gray-400 hover:bg-gray-800 hover:text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent side="right" align="start" className="w-36">
|
||||||
|
{SHAPES.map((shape) => (
|
||||||
|
<button
|
||||||
|
key={shape.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setPendingShape(shape.id);
|
||||||
|
setActiveTool("shape");
|
||||||
|
setShapeOpen(false);
|
||||||
|
}}
|
||||||
|
className="flex w-full rounded-md px-2 py-2 text-left text-sm text-gray-200 hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
{shape.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tool.id}
|
||||||
|
type="button"
|
||||||
|
title={tool.label}
|
||||||
|
onClick={() => {
|
||||||
|
if (tool.id === "ai") {
|
||||||
|
setAiModalOpen(true);
|
||||||
|
} else {
|
||||||
|
setActiveTool(tool.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-10 items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500",
|
||||||
|
activeTool === tool.id
|
||||||
|
? "bg-primary-600 text-white"
|
||||||
|
: "text-gray-400 hover:bg-gray-800 hover:text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Download, FolderOpen, Sparkles } from "lucide-react";
|
||||||
|
|
||||||
|
import { ProjectSaveIndicator } from "@/components/studio/ProjectSaveIndicator";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import { toast } from "@/components/ui/use-toast";
|
||||||
|
import { downloadStageImage } from "@/lib/image-editor-export";
|
||||||
|
import { getImageEditorStage } from "@/lib/image-editor-stage-ref";
|
||||||
|
import type { ExportImageFormat } from "@/lib/image-editor-types";
|
||||||
|
import type { ProjectSaveStatus } from "@/lib/project-save-status";
|
||||||
|
import { useImageEditorStore } from "@/lib/image-editor-store";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface ImageEditorTopBarProps {
|
||||||
|
projectId?: string;
|
||||||
|
projectName?: string;
|
||||||
|
saveStatus?: ProjectSaveStatus;
|
||||||
|
onSaveRetry?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageEditorTopBar({
|
||||||
|
projectId,
|
||||||
|
projectName,
|
||||||
|
saveStatus = "idle",
|
||||||
|
onSaveRetry,
|
||||||
|
}: ImageEditorTopBarProps) {
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [exportOpen, setExportOpen] = useState(false);
|
||||||
|
|
||||||
|
const loadBaseImage = useImageEditorStore((s) => s.loadBaseImage);
|
||||||
|
const exportFormat = useImageEditorStore((s) => s.exportFormat);
|
||||||
|
const exportQuality = useImageEditorStore((s) => s.exportQuality);
|
||||||
|
const setExportFormat = useImageEditorStore((s) => s.setExportFormat);
|
||||||
|
const setExportQuality = useImageEditorStore((s) => s.setExportQuality);
|
||||||
|
const hasImage = useImageEditorStore((s) =>
|
||||||
|
s.layers.some((l) => l.type === "image")
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenFile = (file: File) => {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
const img = new window.Image();
|
||||||
|
img.onload = () => {
|
||||||
|
loadBaseImage(url, img.naturalWidth, img.naturalHeight);
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
const stage = getImageEditorStage();
|
||||||
|
if (!stage) {
|
||||||
|
toast({ title: "Canvas not ready." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
downloadStageImage(stage, exportFormat, exportQuality);
|
||||||
|
toast({ title: "Export started" });
|
||||||
|
setExportOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="flex h-14 shrink-0 items-center justify-between gap-4 border-b border-gray-800 bg-gray-900 px-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
href="/image-maker"
|
||||||
|
className="flex items-center gap-2 text-sm text-gray-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<Sparkles className="h-4 w-4 text-violet-500" />
|
||||||
|
<span className="font-heading font-semibold text-white">
|
||||||
|
{projectName ?? "Image Editor"}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
{projectId ? (
|
||||||
|
<span className="text-xs text-gray-500">{projectId.slice(0, 8)}</span>
|
||||||
|
) : null}
|
||||||
|
<ProjectSaveIndicator status={saveStatus} onRetry={onSaveRetry} />
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) handleOpenFile(file);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="border-gray-700 bg-gray-800 text-gray-200"
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
>
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
Open
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="bg-primary-600 hover:bg-primary-700"
|
||||||
|
disabled={!hasImage}
|
||||||
|
onClick={() => setExportOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
{exportOpen ? (
|
||||||
|
<div className="absolute right-0 top-full z-50 mt-2 w-64 rounded-xl border border-gray-700 bg-gray-900 p-4 shadow-xl">
|
||||||
|
<p className="mb-2 text-xs font-semibold text-gray-400">Format</p>
|
||||||
|
<div className="mb-4 flex gap-2">
|
||||||
|
{(["png", "jpg", "webp"] as ExportImageFormat[]).map((fmt) => (
|
||||||
|
<button
|
||||||
|
key={fmt}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExportFormat(fmt)}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 rounded-lg border py-1.5 text-xs font-medium uppercase",
|
||||||
|
exportFormat === fmt
|
||||||
|
? "border-primary-500 bg-primary-600/20 text-white"
|
||||||
|
: "border-gray-700 text-gray-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{fmt}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{exportFormat !== "png" ? (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="mb-2 flex justify-between text-xs text-gray-400">
|
||||||
|
<span>Quality</span>
|
||||||
|
<span>{exportQuality}%</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
min={60}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
value={[exportQuality]}
|
||||||
|
onValueChange={([v]) => setExportQuality(v ?? 90)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full bg-primary-600 hover:bg-primary-700"
|
||||||
|
onClick={handleExport}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Image } from "react-konva";
|
||||||
|
import type Konva from "konva";
|
||||||
|
import useImage from "use-image";
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyAdjustmentsToNode,
|
||||||
|
buildKonvaFilterList,
|
||||||
|
} from "@/lib/image-editor-konva";
|
||||||
|
import type { ImageAdjustments, ImageLayer } from "@/lib/image-editor-types";
|
||||||
|
|
||||||
|
interface ImageBaseLayerProps {
|
||||||
|
layer: ImageLayer;
|
||||||
|
adjustments: ImageAdjustments;
|
||||||
|
interactive?: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
registerNode: (id: string, node: Konva.Node | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageBaseLayer({
|
||||||
|
layer,
|
||||||
|
adjustments,
|
||||||
|
interactive = true,
|
||||||
|
onSelect,
|
||||||
|
registerNode,
|
||||||
|
}: ImageBaseLayerProps) {
|
||||||
|
const [konvaNode, setKonvaNode] = useState<Konva.Image | null>(null);
|
||||||
|
const src =
|
||||||
|
typeof layer.props.src === "string" ? layer.props.src : undefined;
|
||||||
|
const [image] = useImage(src ?? "", "anonymous");
|
||||||
|
const filters = buildKonvaFilterList(adjustments);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!konvaNode || !image) return;
|
||||||
|
applyAdjustmentsToNode(konvaNode, adjustments, filters);
|
||||||
|
}, [konvaNode, image, adjustments, filters]);
|
||||||
|
|
||||||
|
if (!image) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
ref={(node) => {
|
||||||
|
registerNode(layer.id, node);
|
||||||
|
setKonvaNode(node);
|
||||||
|
}}
|
||||||
|
image={image}
|
||||||
|
x={layer.x}
|
||||||
|
y={layer.y}
|
||||||
|
width={layer.width}
|
||||||
|
height={layer.height}
|
||||||
|
rotation={layer.rotation}
|
||||||
|
opacity={layer.opacity}
|
||||||
|
listening={interactive}
|
||||||
|
onMouseDown={
|
||||||
|
interactive
|
||||||
|
? (event) => {
|
||||||
|
event.cancelBubble = true;
|
||||||
|
onSelect();
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onTap={
|
||||||
|
interactive
|
||||||
|
? (event) => {
|
||||||
|
event.cancelBubble = true;
|
||||||
|
onSelect();
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Rnd } from "react-rnd";
|
||||||
|
|
||||||
|
import { getCropAspectRatioValue } from "@/lib/image-editor-crop";
|
||||||
|
import type { CropRect, ImageCropAspectRatio } from "@/lib/image-editor-types";
|
||||||
|
|
||||||
|
interface ImageCropOverlayProps {
|
||||||
|
cropRect: CropRect;
|
||||||
|
scale: number;
|
||||||
|
aspectRatio: ImageCropAspectRatio;
|
||||||
|
onCropChange: (rect: CropRect) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageCropOverlay({
|
||||||
|
cropRect,
|
||||||
|
scale,
|
||||||
|
aspectRatio,
|
||||||
|
onCropChange,
|
||||||
|
}: ImageCropOverlayProps) {
|
||||||
|
const lockRatio = getCropAspectRatioValue(aspectRatio);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Rnd
|
||||||
|
size={{
|
||||||
|
width: cropRect.w * scale,
|
||||||
|
height: cropRect.h * scale,
|
||||||
|
}}
|
||||||
|
position={{
|
||||||
|
x: cropRect.x * scale,
|
||||||
|
y: cropRect.y * scale,
|
||||||
|
}}
|
||||||
|
bounds="parent"
|
||||||
|
lockAspectRatio={lockRatio}
|
||||||
|
onDragStop={(_e, data) =>
|
||||||
|
onCropChange({
|
||||||
|
...cropRect,
|
||||||
|
x: data.x / scale,
|
||||||
|
y: data.y / scale,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onResizeStop={(_e, _dir, ref, _delta, position) =>
|
||||||
|
onCropChange({
|
||||||
|
x: position.x / scale,
|
||||||
|
y: position.y / scale,
|
||||||
|
w: ref.offsetWidth / scale,
|
||||||
|
h: ref.offsetHeight / scale,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="border-2 border-dashed border-violet-500 bg-violet-500/10"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Layer, Rect, Stage, Transformer } from "react-konva";
|
||||||
|
import type Konva from "konva";
|
||||||
|
import { ImageCropOverlay } from "@/components/image-editor/canvas/ImageCropOverlay";
|
||||||
|
import { ImageEditorLayerNode } from "@/components/image-editor/canvas/ImageEditorLayerNode";
|
||||||
|
import { VignetteOverlay } from "@/components/image-editor/canvas/VignetteOverlay";
|
||||||
|
import { useContainerSize } from "@/hooks/useContainerSize";
|
||||||
|
import {
|
||||||
|
nodeToImageLayer,
|
||||||
|
resetNodeScale,
|
||||||
|
} from "@/lib/image-editor-transform";
|
||||||
|
import { registerImageEditorStage } from "@/lib/image-editor-stage-ref";
|
||||||
|
import {
|
||||||
|
getBaseImageLayer,
|
||||||
|
useImageEditorStore,
|
||||||
|
} from "@/lib/image-editor-store";
|
||||||
|
|
||||||
|
export function ImageEditorCanvas() {
|
||||||
|
const { ref: containerRef, width: cw, height: ch } = useContainerSize();
|
||||||
|
const transformerRef = useRef<Konva.Transformer>(null);
|
||||||
|
const nodeRefs = useRef<Map<string, Konva.Node>>(new Map());
|
||||||
|
const [drawPoints, setDrawPoints] = useState<number[]>([]);
|
||||||
|
const pendingShape = useImageEditorStore((s) => s.pendingShape);
|
||||||
|
|
||||||
|
const canvasWidth = useImageEditorStore((s) => s.canvasWidth);
|
||||||
|
const canvasHeight = useImageEditorStore((s) => s.canvasHeight);
|
||||||
|
const layers = useImageEditorStore((s) => s.layers);
|
||||||
|
const selectedLayerId = useImageEditorStore((s) => s.selectedLayerId);
|
||||||
|
const activeTool = useImageEditorStore((s) => s.activeTool);
|
||||||
|
const adjustments = useImageEditorStore((s) => s.adjustments);
|
||||||
|
const cropRect = useImageEditorStore((s) => s.cropRect);
|
||||||
|
const cropAspectRatio = useImageEditorStore((s) => s.cropAspectRatio);
|
||||||
|
const setSelectedLayer = useImageEditorStore((s) => s.setSelectedLayer);
|
||||||
|
const updateLayer = useImageEditorStore((s) => s.updateLayer);
|
||||||
|
const setCropRect = useImageEditorStore((s) => s.setCropRect);
|
||||||
|
const addLayer = useImageEditorStore((s) => s.addLayer);
|
||||||
|
|
||||||
|
const scale = cw > 0 ? Math.min(cw / canvasWidth, ch / canvasHeight) : 1;
|
||||||
|
const stageW = canvasWidth * scale;
|
||||||
|
const stageH = canvasHeight * scale;
|
||||||
|
|
||||||
|
const sorted = useMemo(
|
||||||
|
() => [...layers].sort((a, b) => a.zIndex - b.zIndex),
|
||||||
|
[layers]
|
||||||
|
);
|
||||||
|
const baseLayer = getBaseImageLayer({ layers });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tr = transformerRef.current;
|
||||||
|
if (!tr || activeTool !== "select") {
|
||||||
|
tr?.nodes([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedLayerId) {
|
||||||
|
tr.nodes([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const node = nodeRefs.current.get(selectedLayerId);
|
||||||
|
if (node) {
|
||||||
|
tr.nodes([node]);
|
||||||
|
tr.getLayer()?.batchDraw();
|
||||||
|
}
|
||||||
|
}, [selectedLayerId, sorted, activeTool]);
|
||||||
|
|
||||||
|
const pointerToCanvas = useCallback(
|
||||||
|
(stage: Konva.Stage) => {
|
||||||
|
const pos = stage.getPointerPosition();
|
||||||
|
if (!pos) return null;
|
||||||
|
return { x: pos.x / scale, y: pos.y / scale };
|
||||||
|
},
|
||||||
|
[scale]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStagePointerDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||||
|
const stage = e.target.getStage();
|
||||||
|
if (!stage) return;
|
||||||
|
const pt = pointerToCanvas(stage);
|
||||||
|
if (!pt) return;
|
||||||
|
|
||||||
|
if (activeTool === "text") {
|
||||||
|
addLayer({
|
||||||
|
type: "text",
|
||||||
|
name: "Text",
|
||||||
|
x: pt.x,
|
||||||
|
y: pt.y,
|
||||||
|
width: 280,
|
||||||
|
height: 48,
|
||||||
|
props: { text: "New text", fontSize: 36, fill: "#ffffff" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTool === "shape") {
|
||||||
|
addLayer({
|
||||||
|
type: "shape",
|
||||||
|
name: pendingShape,
|
||||||
|
x: pt.x,
|
||||||
|
y: pt.y,
|
||||||
|
width: pendingShape === "line" ? 160 : 120,
|
||||||
|
height: pendingShape === "line" ? 8 : 120,
|
||||||
|
props: { shape: pendingShape, fill: "#2563EB" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTool === "draw") {
|
||||||
|
setDrawPoints([pt.x, pt.y]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.target === stage) setSelectedLayer(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStagePointerMove = (e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||||
|
if (activeTool !== "draw" || drawPoints.length === 0) return;
|
||||||
|
const stage = e.target.getStage();
|
||||||
|
if (!stage) return;
|
||||||
|
const pt = pointerToCanvas(stage);
|
||||||
|
if (!pt) return;
|
||||||
|
setDrawPoints((prev) => [...prev, pt.x, pt.y]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStagePointerUp = () => {
|
||||||
|
if (activeTool !== "draw" || drawPoints.length < 4) {
|
||||||
|
setDrawPoints([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addLayer({
|
||||||
|
type: "draw",
|
||||||
|
name: "Drawing",
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: canvasWidth,
|
||||||
|
height: canvasHeight,
|
||||||
|
props: { points: drawPoints, stroke: "#ffffff", strokeWidth: 4 },
|
||||||
|
});
|
||||||
|
setDrawPoints([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCropping = activeTool === "crop";
|
||||||
|
|
||||||
|
if (cw <= 0) {
|
||||||
|
return <div ref={containerRef} className="h-full w-full bg-gray-950" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="relative flex h-full w-full items-center justify-center overflow-hidden bg-gray-950"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative shadow-2xl"
|
||||||
|
style={{ width: stageW, height: stageH }}
|
||||||
|
>
|
||||||
|
<Stage
|
||||||
|
ref={(node) => registerImageEditorStage(node)}
|
||||||
|
width={stageW}
|
||||||
|
height={stageH}
|
||||||
|
scaleX={scale}
|
||||||
|
scaleY={scale}
|
||||||
|
onMouseDown={isCropping ? undefined : handleStagePointerDown}
|
||||||
|
onMousemove={isCropping ? undefined : handleStagePointerMove}
|
||||||
|
onMouseup={isCropping ? undefined : handleStagePointerUp}
|
||||||
|
className="bg-checkerboard"
|
||||||
|
>
|
||||||
|
<Layer>
|
||||||
|
<Rect
|
||||||
|
x={0}
|
||||||
|
y={0}
|
||||||
|
width={canvasWidth}
|
||||||
|
height={canvasHeight}
|
||||||
|
fill="#ffffff"
|
||||||
|
listening={false}
|
||||||
|
/>
|
||||||
|
{sorted.map((layer) => (
|
||||||
|
<ImageEditorLayerNode
|
||||||
|
key={layer.id}
|
||||||
|
layer={layer}
|
||||||
|
adjustments={adjustments}
|
||||||
|
isBaseImage={layer.id === baseLayer?.id}
|
||||||
|
interactive={!isCropping}
|
||||||
|
onSelect={() => setSelectedLayer(layer.id)}
|
||||||
|
onDragEnd={(x, y) => updateLayer(layer.id, { x, y })}
|
||||||
|
onTransformEnd={(node) => {
|
||||||
|
resetNodeScale(node);
|
||||||
|
updateLayer(layer.id, nodeToImageLayer(node));
|
||||||
|
}}
|
||||||
|
registerNode={(id, node) => {
|
||||||
|
if (node) nodeRefs.current.set(id, node);
|
||||||
|
else nodeRefs.current.delete(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{drawPoints.length > 0 ? (
|
||||||
|
<ImageEditorLayerNode
|
||||||
|
layer={{
|
||||||
|
id: "preview-draw",
|
||||||
|
type: "draw",
|
||||||
|
name: "preview",
|
||||||
|
visible: true,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: canvasWidth,
|
||||||
|
height: canvasHeight,
|
||||||
|
rotation: 0,
|
||||||
|
opacity: 1,
|
||||||
|
zIndex: 9999,
|
||||||
|
props: {
|
||||||
|
points: drawPoints,
|
||||||
|
stroke: "#ffffff",
|
||||||
|
strokeWidth: 4,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
adjustments={adjustments}
|
||||||
|
isBaseImage={false}
|
||||||
|
interactive={false}
|
||||||
|
onSelect={() => undefined}
|
||||||
|
onDragEnd={() => undefined}
|
||||||
|
onTransformEnd={() => undefined}
|
||||||
|
registerNode={() => undefined}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<VignetteOverlay
|
||||||
|
width={canvasWidth}
|
||||||
|
height={canvasHeight}
|
||||||
|
amount={adjustments.vignette}
|
||||||
|
/>
|
||||||
|
{activeTool === "select" ? (
|
||||||
|
<Transformer ref={transformerRef} rotateEnabled borderStroke="#7C3AED" />
|
||||||
|
) : null}
|
||||||
|
</Layer>
|
||||||
|
</Stage>
|
||||||
|
{isCropping && cropRect ? (
|
||||||
|
<ImageCropOverlay
|
||||||
|
cropRect={cropRect}
|
||||||
|
scale={scale}
|
||||||
|
aspectRatio={cropAspectRatio}
|
||||||
|
onCropChange={setCropRect}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Arrow, Circle, Line, Rect, Text } from "react-konva";
|
||||||
|
import type Konva from "konva";
|
||||||
|
|
||||||
|
import { ImageBaseLayer } from "@/components/image-editor/canvas/ImageBaseLayer";
|
||||||
|
import type {
|
||||||
|
ImageAdjustments,
|
||||||
|
ImageLayer,
|
||||||
|
ImageShapeKind,
|
||||||
|
} from "@/lib/image-editor-types";
|
||||||
|
|
||||||
|
interface ImageEditorLayerNodeProps {
|
||||||
|
layer: ImageLayer;
|
||||||
|
adjustments: ImageAdjustments;
|
||||||
|
isBaseImage: boolean;
|
||||||
|
interactive?: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
onDragEnd: (x: number, y: number) => void;
|
||||||
|
onTransformEnd: (node: Konva.Node) => void;
|
||||||
|
registerNode: (id: string, node: Konva.Node | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageEditorLayerNode({
|
||||||
|
layer,
|
||||||
|
adjustments,
|
||||||
|
isBaseImage,
|
||||||
|
interactive = true,
|
||||||
|
onSelect,
|
||||||
|
onDragEnd,
|
||||||
|
onTransformEnd,
|
||||||
|
registerNode,
|
||||||
|
}: ImageEditorLayerNodeProps) {
|
||||||
|
if (!layer.visible) return null;
|
||||||
|
|
||||||
|
if (layer.type === "image") {
|
||||||
|
return (
|
||||||
|
<ImageBaseLayer
|
||||||
|
layer={layer}
|
||||||
|
adjustments={adjustments}
|
||||||
|
interactive={interactive}
|
||||||
|
onSelect={onSelect}
|
||||||
|
registerNode={registerNode}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const common = {
|
||||||
|
rotation: layer.rotation,
|
||||||
|
opacity: layer.opacity,
|
||||||
|
listening: interactive,
|
||||||
|
draggable: interactive && !isBaseImage,
|
||||||
|
onMouseDown: interactive
|
||||||
|
? (e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||||
|
e.cancelBubble = true;
|
||||||
|
onSelect();
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
onTap: interactive
|
||||||
|
? (e: Konva.KonvaEventObject<TouchEvent>) => {
|
||||||
|
e.cancelBubble = true;
|
||||||
|
onSelect();
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
onDragEnd: interactive
|
||||||
|
? (e: Konva.KonvaEventObject<DragEvent>) =>
|
||||||
|
onDragEnd(e.target.x(), e.target.y())
|
||||||
|
: undefined,
|
||||||
|
onTransformEnd: interactive
|
||||||
|
? (e: Konva.KonvaEventObject<Event>) => onTransformEnd(e.target)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (layer.type === "text") {
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
ref={(n) => registerNode(layer.id, n)}
|
||||||
|
x={layer.x}
|
||||||
|
y={layer.y}
|
||||||
|
width={layer.width}
|
||||||
|
text={typeof layer.props.text === "string" ? layer.props.text : "Text"}
|
||||||
|
fontSize={
|
||||||
|
typeof layer.props.fontSize === "number" ? layer.props.fontSize : 36
|
||||||
|
}
|
||||||
|
fill={
|
||||||
|
typeof layer.props.fill === "string" ? layer.props.fill : "#ffffff"
|
||||||
|
}
|
||||||
|
{...common}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layer.type === "draw") {
|
||||||
|
const points = Array.isArray(layer.props.points)
|
||||||
|
? (layer.props.points as number[])
|
||||||
|
: [];
|
||||||
|
return (
|
||||||
|
<Line
|
||||||
|
ref={(n) => registerNode(layer.id, n)}
|
||||||
|
points={points}
|
||||||
|
x={layer.x}
|
||||||
|
y={layer.y}
|
||||||
|
stroke={
|
||||||
|
typeof layer.props.stroke === "string"
|
||||||
|
? layer.props.stroke
|
||||||
|
: "#ffffff"
|
||||||
|
}
|
||||||
|
strokeWidth={
|
||||||
|
typeof layer.props.strokeWidth === "number"
|
||||||
|
? layer.props.strokeWidth
|
||||||
|
: 4
|
||||||
|
}
|
||||||
|
tension={0.5}
|
||||||
|
lineCap="round"
|
||||||
|
lineJoin="round"
|
||||||
|
{...common}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layer.type === "shape") {
|
||||||
|
const shape = (layer.props.shape as ImageShapeKind) ?? "rect";
|
||||||
|
const fill =
|
||||||
|
typeof layer.props.fill === "string" ? layer.props.fill : "#2563EB";
|
||||||
|
if (shape === "circle") {
|
||||||
|
const r = Math.min(layer.width, layer.height) / 2;
|
||||||
|
return (
|
||||||
|
<Circle
|
||||||
|
ref={(n) => registerNode(layer.id, n)}
|
||||||
|
x={layer.x + layer.width / 2}
|
||||||
|
y={layer.y + layer.height / 2}
|
||||||
|
radius={r}
|
||||||
|
fill={fill}
|
||||||
|
{...common}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (shape === "line") {
|
||||||
|
return (
|
||||||
|
<Line
|
||||||
|
ref={(n) => registerNode(layer.id, n)}
|
||||||
|
x={layer.x}
|
||||||
|
y={layer.y}
|
||||||
|
points={[0, 0, layer.width, layer.height]}
|
||||||
|
stroke={fill}
|
||||||
|
strokeWidth={4}
|
||||||
|
{...common}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (shape === "arrow") {
|
||||||
|
return (
|
||||||
|
<Arrow
|
||||||
|
ref={(n) => registerNode(layer.id, n)}
|
||||||
|
x={layer.x}
|
||||||
|
y={layer.y + layer.height / 2}
|
||||||
|
points={[0, 0, layer.width, 0]}
|
||||||
|
fill={fill}
|
||||||
|
stroke={fill}
|
||||||
|
pointerLength={10}
|
||||||
|
pointerWidth={10}
|
||||||
|
{...common}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Rect
|
||||||
|
ref={(n) => registerNode(layer.id, n)}
|
||||||
|
x={layer.x}
|
||||||
|
y={layer.y}
|
||||||
|
width={layer.width}
|
||||||
|
height={layer.height}
|
||||||
|
fill={fill}
|
||||||
|
{...common}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Rect } from "react-konva";
|
||||||
|
|
||||||
|
interface VignetteOverlayProps {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VignetteOverlay({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
amount,
|
||||||
|
}: VignetteOverlayProps) {
|
||||||
|
if (amount <= 0) return null;
|
||||||
|
|
||||||
|
const opacity = Math.min(0.85, amount / 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Rect
|
||||||
|
x={0}
|
||||||
|
y={0}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
fillRadialGradientStartPoint={{ x: width / 2, y: height / 2 }}
|
||||||
|
fillRadialGradientStartRadius={0}
|
||||||
|
fillRadialGradientEndPoint={{ x: width / 2, y: height / 2 }}
|
||||||
|
fillRadialGradientEndRadius={Math.max(width, height) / 1.1}
|
||||||
|
fillRadialGradientColorStops={[
|
||||||
|
0,
|
||||||
|
"rgba(0,0,0,0)",
|
||||||
|
1,
|
||||||
|
`rgba(0,0,0,${opacity})`,
|
||||||
|
]}
|
||||||
|
listening={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import { useImageEditorStore } from "@/lib/image-editor-store";
|
||||||
|
|
||||||
|
const SLIDERS = [
|
||||||
|
{ key: "brightness" as const, label: "Brightness", min: -100, max: 100 },
|
||||||
|
{ key: "contrast" as const, label: "Contrast", min: -100, max: 100 },
|
||||||
|
{ key: "saturation" as const, label: "Saturation", min: -100, max: 100 },
|
||||||
|
{ key: "hue" as const, label: "Hue", min: -180, max: 180 },
|
||||||
|
{ key: "blur" as const, label: "Blur", min: 0, max: 20 },
|
||||||
|
{ key: "sharpen" as const, label: "Sharpen", min: 0, max: 10 },
|
||||||
|
{ key: "vignette" as const, label: "Vignette", min: 0, max: 100 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AdjustPanel() {
|
||||||
|
const adjustments = useImageEditorStore((s) => s.adjustments);
|
||||||
|
const setAdjustments = useImageEditorStore((s) => s.setAdjustments);
|
||||||
|
const hasBase = useImageEditorStore((s) => s.layers.some((l) => l.type === "image"));
|
||||||
|
|
||||||
|
if (!hasBase) {
|
||||||
|
return (
|
||||||
|
<p className="text-xs text-gray-500">Open an image to use adjustments.</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{SLIDERS.map(({ key, label, min, max }) => (
|
||||||
|
<div key={key}>
|
||||||
|
<div className="mb-2 flex justify-between text-xs text-gray-400">
|
||||||
|
<span>{label}</span>
|
||||||
|
<span className="tabular-nums text-gray-300">
|
||||||
|
{adjustments[key]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={key === "hue" ? 1 : key === "blur" ? 0.5 : 1}
|
||||||
|
value={[adjustments[key]]}
|
||||||
|
onValueChange={([value]) =>
|
||||||
|
setAdjustments({ [key]: value ?? adjustments[key] })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FILTER_PRESETS } from "@/lib/image-editor-filters";
|
||||||
|
import { useImageEditorStore } from "@/lib/image-editor-store";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function FiltersPanel() {
|
||||||
|
const activeFilterPreset = useImageEditorStore((s) => s.activeFilterPreset);
|
||||||
|
const applyFilterPreset = useImageEditorStore((s) => s.applyFilterPreset);
|
||||||
|
const hasBase = useImageEditorStore((s) => s.layers.some((l) => l.type === "image"));
|
||||||
|
|
||||||
|
if (!hasBase) {
|
||||||
|
return <p className="text-xs text-gray-500">Open an image to apply filters.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{FILTER_PRESETS.map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyFilterPreset(preset.id)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border px-2 py-3 text-left text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500",
|
||||||
|
activeFilterPreset === preset.id
|
||||||
|
? "border-primary-500 bg-primary-600/20 text-white"
|
||||||
|
: "border-gray-700 bg-gray-800 text-gray-300 hover:border-gray-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="mb-2 block h-10 w-full rounded-md"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
preset.id === "bw"
|
||||||
|
? "linear-gradient(135deg,#6b7280,#111827)"
|
||||||
|
: preset.id === "vivid"
|
||||||
|
? "linear-gradient(135deg,#f59e0b,#ef4444)"
|
||||||
|
: preset.id === "cool"
|
||||||
|
? "linear-gradient(135deg,#38bdf8,#6366f1)"
|
||||||
|
: preset.id === "warm"
|
||||||
|
? "linear-gradient(135deg,#fb923c,#facc15)"
|
||||||
|
: "linear-gradient(135deg,#4b5563,#9ca3af)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
type DragEndEvent,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import { Eye, EyeOff, GripVertical, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
import type { ImageLayer } from "@/lib/image-editor-types";
|
||||||
|
import { useImageEditorStore } from "@/lib/image-editor-store";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function layerIcon(type: ImageLayer["type"]): string {
|
||||||
|
switch (type) {
|
||||||
|
case "image":
|
||||||
|
return "🖼";
|
||||||
|
case "text":
|
||||||
|
return "T";
|
||||||
|
case "shape":
|
||||||
|
return "□";
|
||||||
|
case "draw":
|
||||||
|
return "✎";
|
||||||
|
default:
|
||||||
|
return "•";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableLayerRow({ layer }: { layer: ImageLayer }) {
|
||||||
|
const selectedLayerId = useImageEditorStore((s) => s.selectedLayerId);
|
||||||
|
const setSelectedLayer = useImageEditorStore((s) => s.setSelectedLayer);
|
||||||
|
const toggleLayerVisibility = useImageEditorStore((s) => s.toggleLayerVisibility);
|
||||||
|
const deleteLayer = useImageEditorStore((s) => s.deleteLayer);
|
||||||
|
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||||
|
useSortable({ id: layer.id });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={{
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 rounded-lg border px-2 py-2",
|
||||||
|
selectedLayerId === layer.id
|
||||||
|
? "border-primary-500 bg-primary-600/15"
|
||||||
|
: "border-gray-700 bg-gray-800/80"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="cursor-grab text-gray-500 hover:text-gray-300"
|
||||||
|
aria-label={`Reorder ${layer.name}`}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedLayer(layer.id)}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-2 text-left text-xs text-gray-200"
|
||||||
|
>
|
||||||
|
<span className="w-4 text-center">{layerIcon(layer.type)}</span>
|
||||||
|
<span className="truncate">{layer.name}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleLayerVisibility(layer.id)}
|
||||||
|
className="text-gray-400 hover:text-white"
|
||||||
|
aria-label={layer.visible ? "Hide layer" : "Show layer"}
|
||||||
|
>
|
||||||
|
{layer.visible ? (
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<EyeOff className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{layer.type !== "image" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => deleteLayer(layer.id)}
|
||||||
|
className="text-gray-400 hover:text-red-400"
|
||||||
|
aria-label={`Delete ${layer.name}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="w-3.5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LayersPanel() {
|
||||||
|
const layers = useImageEditorStore((s) => s.layers);
|
||||||
|
const reorderLayers = useImageEditorStore((s) => s.reorderLayers);
|
||||||
|
|
||||||
|
const reversed = [...layers].sort((a, b) => b.zIndex - a.zIndex);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
|
||||||
|
const oldIndex = reversed.findIndex((l) => l.id === active.id);
|
||||||
|
const newIndex = reversed.findIndex((l) => l.id === over.id);
|
||||||
|
if (oldIndex === -1 || newIndex === -1) return;
|
||||||
|
|
||||||
|
const next = [...reversed];
|
||||||
|
const [moved] = next.splice(oldIndex, 1);
|
||||||
|
next.splice(newIndex, 0, moved);
|
||||||
|
reorderLayers([...next].reverse().map((l) => l.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (layers.length === 0) {
|
||||||
|
return <p className="text-xs text-gray-500">No layers yet.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={reversed.map((l) => l.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{reversed.map((layer) => (
|
||||||
|
<SortableLayerRow key={layer.id} layer={layer} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { OptimizedImage } from "@/components/ui/optimized-image";
|
||||||
|
|
||||||
|
export function ImageMakerBeforeAfter() {
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-xl border border-gray-100 bg-white shadow-xl">
|
||||||
|
<div className="grid grid-cols-2 divide-x divide-gray-100">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="relative aspect-[4/5] sm:aspect-square">
|
||||||
|
<OptimizedImage
|
||||||
|
src="https://picsum.photos/seed/im-before/400/500"
|
||||||
|
alt="Before editing"
|
||||||
|
fill
|
||||||
|
priority
|
||||||
|
sizes="(max-width: 1024px) 50vw, 320px"
|
||||||
|
className="object-cover grayscale"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="absolute left-3 top-3 rounded-md bg-neutral-900/70 px-2 py-1 text-xs font-semibold text-white backdrop-blur-sm">
|
||||||
|
Before
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="relative aspect-[4/5] sm:aspect-square">
|
||||||
|
<OptimizedImage
|
||||||
|
src="https://picsum.photos/seed/im-after/400/500"
|
||||||
|
alt="After editing with AI"
|
||||||
|
fill
|
||||||
|
priority
|
||||||
|
sizes="(max-width: 1024px) 50vw, 320px"
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="absolute left-3 top-3 rounded-md bg-violet-600 px-2 py-1 text-xs font-semibold text-white">
|
||||||
|
After
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="border-t border-gray-100 bg-neutral-50 px-4 py-3 text-center text-xs text-neutral-500">
|
||||||
|
AI-enhanced color, layout, and brand styling applied in one click
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { SectionReveal } from "@/components/sections/SectionReveal";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export function ImageMakerCta() {
|
||||||
|
return (
|
||||||
|
<section className="bg-violet-600 py-20 sm:py-24">
|
||||||
|
<div className="mx-auto max-w-3xl px-4 text-center sm:px-6 lg:px-8">
|
||||||
|
<SectionReveal>
|
||||||
|
<h2 className="font-heading text-3xl font-bold text-white sm:text-4xl">
|
||||||
|
Start designing your next visual today
|
||||||
|
</h2>
|
||||||
|
<p className="mt-4 text-lg text-violet-100">
|
||||||
|
Free plan includes exports and basic templates. Upgrade anytime for AI
|
||||||
|
generation and brand kits.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="mt-8 bg-white text-violet-600 hover:bg-violet-50"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link href="/auth?tab=sign-up">Start Creating Images Free</Link>
|
||||||
|
</Button>
|
||||||
|
</SectionReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Files,
|
||||||
|
LayoutTemplate,
|
||||||
|
Maximize2,
|
||||||
|
Palette,
|
||||||
|
Sparkles,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { SectionReveal } from "@/components/sections/SectionReveal";
|
||||||
|
|
||||||
|
interface Feature {
|
||||||
|
icon: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const features: Feature[] = [
|
||||||
|
{
|
||||||
|
icon: Sparkles,
|
||||||
|
title: "AI image generation",
|
||||||
|
description:
|
||||||
|
"Describe your idea and get on-brand visuals, backgrounds, and product shots in seconds.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: LayoutTemplate,
|
||||||
|
title: "Templates",
|
||||||
|
description:
|
||||||
|
"Start from layouts built for posts, stories, ads, and presentations—fully editable.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Maximize2,
|
||||||
|
title: "Resize for any platform",
|
||||||
|
description:
|
||||||
|
"One design, every size: Instagram, LinkedIn, banners, and print-ready exports.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Palette,
|
||||||
|
title: "Brand kit",
|
||||||
|
description:
|
||||||
|
"Lock logos, fonts, and colors so every asset stays consistent across your team.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Files,
|
||||||
|
title: "Batch export",
|
||||||
|
description:
|
||||||
|
"Export dozens of variations at once for campaigns, locales, and A/B tests.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ImageMakerFeatures() {
|
||||||
|
return (
|
||||||
|
<section className="bg-neutral-50 py-20 sm:py-28">
|
||||||
|
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
|
<SectionReveal className="text-center">
|
||||||
|
<h2 className="font-heading text-3xl font-bold text-neutral-900 sm:text-4xl">
|
||||||
|
Design smarter, not harder
|
||||||
|
</h2>
|
||||||
|
<p className="mx-auto mt-4 max-w-2xl text-neutral-600">
|
||||||
|
CreatorStudio Image Maker combines AI generation with pro layout tools
|
||||||
|
in one workflow.
|
||||||
|
</p>
|
||||||
|
</SectionReveal>
|
||||||
|
|
||||||
|
<SectionReveal className="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{features.map((feature) => {
|
||||||
|
const Icon = feature.icon;
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
key={feature.title}
|
||||||
|
className="rounded-xl border border-gray-100 bg-white p-6 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex h-11 w-11 items-center justify-center rounded-lg bg-violet-600 text-white">
|
||||||
|
<Icon className="h-5 w-5" aria-hidden />
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-4 font-heading text-lg font-semibold text-neutral-900">
|
||||||
|
{feature.title}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-neutral-600">
|
||||||
|
{feature.description}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SectionReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { OptimizedImage } from "@/components/ui/optimized-image";
|
||||||
|
import { SectionReveal } from "@/components/sections/SectionReveal";
|
||||||
|
|
||||||
|
import { GALLERY_ITEMS } from "./image-maker-gallery-data";
|
||||||
|
|
||||||
|
export function ImageMakerGallery() {
|
||||||
|
return (
|
||||||
|
<section id="gallery" className="bg-neutral-50 py-20 sm:py-28">
|
||||||
|
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
|
<SectionReveal>
|
||||||
|
<h2 className="text-center font-heading text-3xl font-bold text-neutral-900 sm:text-4xl">
|
||||||
|
Example outputs from creators
|
||||||
|
</h2>
|
||||||
|
<p className="mx-auto mt-4 max-w-2xl text-center text-neutral-600">
|
||||||
|
Real-world layouts and styles you can recreate—or use as inspiration
|
||||||
|
for your next project.
|
||||||
|
</p>
|
||||||
|
</SectionReveal>
|
||||||
|
|
||||||
|
<SectionReveal className="mt-12 columns-2 gap-4 sm:columns-3 lg:columns-4 lg:gap-5">
|
||||||
|
{GALLERY_ITEMS.map((item) => (
|
||||||
|
<article
|
||||||
|
key={item.id}
|
||||||
|
className="mb-4 break-inside-avoid overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm lg:mb-5"
|
||||||
|
>
|
||||||
|
<div className={`relative w-full ${item.aspectClass}`}>
|
||||||
|
<OptimizedImage
|
||||||
|
src={`https://picsum.photos/seed/${item.id}/600/800`}
|
||||||
|
alt={item.alt}
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
|
||||||
|
className="object-cover transition-transform duration-300 ease-out hover:scale-105"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</SectionReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
import { ImageMakerBeforeAfter } from "./ImageMakerBeforeAfter";
|
||||||
|
|
||||||
|
export function ImageMakerHero() {
|
||||||
|
return (
|
||||||
|
<section className="relative overflow-hidden bg-white pb-16 pt-12 sm:pb-20 sm:pt-16">
|
||||||
|
<div className="pointer-events-none absolute -left-32 top-0 h-96 w-96 rounded-full bg-violet-200/40 blur-3xl" />
|
||||||
|
<div className="pointer-events-none absolute -right-32 top-20 h-80 w-80 rounded-full bg-violet-100/60 blur-3xl" />
|
||||||
|
|
||||||
|
<div className="relative mx-auto grid max-w-7xl items-center gap-12 px-4 lg:grid-cols-2 lg:gap-16 sm:px-6 lg:px-8">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-40px" }}
|
||||||
|
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
<span className="inline-flex rounded-full bg-violet-100 px-3 py-1 text-xs font-semibold text-violet-700">
|
||||||
|
Image Maker
|
||||||
|
</span>
|
||||||
|
<h1 className="mt-4 font-heading text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl">
|
||||||
|
AI Image Maker — Design professional visuals instantly
|
||||||
|
</h1>
|
||||||
|
<p className="mt-6 text-lg leading-relaxed text-neutral-600">
|
||||||
|
Generate, resize, and brand every asset for social, ads, and print
|
||||||
|
without switching tools or hiring a designer.
|
||||||
|
</p>
|
||||||
|
<div className="mt-8 flex flex-col gap-4 sm:flex-row">
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="bg-violet-600 hover:bg-violet-700"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link href="/sign-up">Start Creating Images Free</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="lg" asChild>
|
||||||
|
<Link href="#gallery">View example gallery</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 24 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-40px" }}
|
||||||
|
transition={{ duration: 0.4, ease: "easeOut", delay: 0.1 }}
|
||||||
|
>
|
||||||
|
<ImageMakerBeforeAfter />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Hexagon,
|
||||||
|
Image as ImageIcon,
|
||||||
|
RectangleHorizontal,
|
||||||
|
Share2,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { SectionReveal } from "@/components/sections/SectionReveal";
|
||||||
|
|
||||||
|
interface UseCase {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useCases: UseCase[] = [
|
||||||
|
{
|
||||||
|
title: "Social Posts",
|
||||||
|
description:
|
||||||
|
"Square, portrait, and carousel layouts with bold typography and safe zones.",
|
||||||
|
icon: Share2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Thumbnails",
|
||||||
|
description:
|
||||||
|
"High-contrast covers for YouTube, podcasts, and courses that read at any size.",
|
||||||
|
icon: ImageIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Banners",
|
||||||
|
description:
|
||||||
|
"Website heroes, email headers, and ad banners with responsive crop guides.",
|
||||||
|
icon: RectangleHorizontal,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Logos",
|
||||||
|
description:
|
||||||
|
"Vector-friendly marks and lockups with transparent exports for any background.",
|
||||||
|
icon: Hexagon,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ImageMakerUseCases() {
|
||||||
|
return (
|
||||||
|
<section className="bg-white py-20 sm:py-28">
|
||||||
|
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
|
<SectionReveal className="text-center">
|
||||||
|
<h2 className="font-heading text-3xl font-bold text-neutral-900 sm:text-4xl">
|
||||||
|
Visuals for every use case
|
||||||
|
</h2>
|
||||||
|
<p className="mx-auto mt-4 max-w-2xl text-neutral-600">
|
||||||
|
From quick social graphics to polished brand assets—one tool, every
|
||||||
|
format.
|
||||||
|
</p>
|
||||||
|
</SectionReveal>
|
||||||
|
|
||||||
|
<SectionReveal className="mt-12 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{useCases.map((useCase) => {
|
||||||
|
const Icon = useCase.icon;
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
key={useCase.title}
|
||||||
|
className="rounded-xl border border-gray-100 bg-white p-6 shadow-sm transition-shadow hover:shadow-md"
|
||||||
|
>
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-violet-50 text-violet-600">
|
||||||
|
<Icon className="h-6 w-6" aria-hidden />
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-4 font-heading text-lg font-semibold text-neutral-900">
|
||||||
|
{useCase.title}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-neutral-600">
|
||||||
|
{useCase.description}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SectionReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export interface GalleryItem {
|
||||||
|
id: string;
|
||||||
|
alt: string;
|
||||||
|
aspectClass: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GALLERY_ITEMS: GalleryItem[] = [
|
||||||
|
{ id: "im-1", alt: "Social post design", aspectClass: "aspect-[4/5]" },
|
||||||
|
{ id: "im-2", alt: "Product banner", aspectClass: "aspect-square" },
|
||||||
|
{ id: "im-3", alt: "Brand thumbnail", aspectClass: "aspect-[3/2]" },
|
||||||
|
{ id: "im-4", alt: "Story layout", aspectClass: "aspect-[9/16]" },
|
||||||
|
{ id: "im-5", alt: "Ad creative", aspectClass: "aspect-[4/3]" },
|
||||||
|
{ id: "im-6", alt: "Logo mockup", aspectClass: "aspect-square" },
|
||||||
|
{ id: "im-7", alt: "Email header", aspectClass: "aspect-[21/9]" },
|
||||||
|
{ id: "im-8", alt: "Carousel slide", aspectClass: "aspect-[4/5]" },
|
||||||
|
{ id: "im-9", alt: "Presentation cover", aspectClass: "aspect-[3/2]" },
|
||||||
|
{ id: "im-10", alt: "Event poster", aspectClass: "aspect-[2/3]" },
|
||||||
|
{ id: "im-11", alt: "Profile banner", aspectClass: "aspect-[4/3]" },
|
||||||
|
{ id: "im-12", alt: "Sale graphic", aspectClass: "aspect-square" },
|
||||||
|
];
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
CirclePlay,
|
||||||
|
Link as LinkIcon,
|
||||||
|
Share2,
|
||||||
|
Sparkles,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface FooterLink {
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FooterColumnProps {
|
||||||
|
title: string;
|
||||||
|
links: FooterLink[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const socialIcons = [
|
||||||
|
{ key: "socialX" as const, href: "https://twitter.com", icon: X },
|
||||||
|
{ key: "socialInstagram" as const, href: "https://instagram.com", icon: Share2 },
|
||||||
|
{ key: "socialLinkedIn" as const, href: "https://linkedin.com", icon: LinkIcon },
|
||||||
|
{ key: "socialYouTube" as const, href: "https://youtube.com", icon: CirclePlay },
|
||||||
|
];
|
||||||
|
|
||||||
|
function FooterColumn({ title, links }: FooterColumnProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="font-heading text-sm font-semibold text-white">{title}</h3>
|
||||||
|
<ul className="mt-4 space-y-3">
|
||||||
|
{links.map((link) => (
|
||||||
|
<li key={link.href}>
|
||||||
|
<Link
|
||||||
|
href={link.href}
|
||||||
|
className="text-sm text-neutral-400 transition-colors hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-900 rounded-sm"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Footer() {
|
||||||
|
const t = useTranslations("footer");
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
|
const productLinks: FooterLink[] = [
|
||||||
|
{ label: t("videoMaker"), href: "/video-maker" },
|
||||||
|
{ label: t("imageMaker"), href: "/image-maker" },
|
||||||
|
{ label: t("templates"), href: "#templates" },
|
||||||
|
{ label: t("pricingLink"), href: "#pricing" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const companyLinks: FooterLink[] = [
|
||||||
|
{ label: t("about"), href: "/about" },
|
||||||
|
{ label: t("blog"), href: "/blog" },
|
||||||
|
{ label: t("careers"), href: "/careers" },
|
||||||
|
{ label: t("contact"), href: "/contact" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const legalLinks: FooterLink[] = [
|
||||||
|
{ label: t("privacy"), href: "/privacy" },
|
||||||
|
{ label: t("terms"), href: "/terms" },
|
||||||
|
{ label: t("cookies"), href: "/cookies" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="bg-slate-900 text-neutral-300">
|
||||||
|
<div className="mx-auto max-w-7xl px-4 py-14 sm:px-6 lg:px-8">
|
||||||
|
<div className="grid grid-cols-1 gap-10 sm:grid-cols-2 lg:grid-cols-4 lg:gap-12">
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
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>
|
||||||
|
<span className="font-heading text-lg font-bold text-white">
|
||||||
|
{t("brandName")}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<p className="mt-4 max-w-xs text-sm leading-relaxed text-neutral-400">
|
||||||
|
{t("description")}
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 flex items-center gap-3">
|
||||||
|
{socialIcons.map((social) => {
|
||||||
|
const Icon = social.icon;
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={social.key}
|
||||||
|
href={social.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={t(social.key)}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-9 items-center justify-center rounded-lg border border-slate-700 text-neutral-400 transition-colors hover:border-slate-600 hover:bg-slate-800 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-900"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" aria-hidden />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FooterColumn title={t("products")} links={productLinks} />
|
||||||
|
<FooterColumn title={t("company")} links={companyLinks} />
|
||||||
|
<FooterColumn title={t("legal")} links={legalLinks} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 flex flex-col items-center justify-between gap-4 border-t border-slate-800 pt-8 sm:flex-row">
|
||||||
|
<p className="text-sm text-neutral-400">
|
||||||
|
{t("rights", { year })}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-neutral-400">{t("madeWith")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition } from "react";
|
||||||
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { Globe } from "lucide-react";
|
||||||
|
|
||||||
|
import { routing, type Locale } from "@/i18n/routing";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function LanguageSwitcher({ className }: { className?: string }) {
|
||||||
|
const locale = useLocale() as Locale;
|
||||||
|
const t = useTranslations("langSwitcher");
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const toggleLocale = () => {
|
||||||
|
const nextLocale: Locale = locale === "fa" ? "en" : "fa";
|
||||||
|
|
||||||
|
// Strip existing locale prefix from path, then prepend new one if needed
|
||||||
|
let newPath = pathname;
|
||||||
|
for (const loc of routing.locales) {
|
||||||
|
if (newPath.startsWith(`/${loc}/`)) {
|
||||||
|
newPath = newPath.slice(loc.length + 1); // remove /en
|
||||||
|
break;
|
||||||
|
} else if (newPath === `/${loc}`) {
|
||||||
|
newPath = "/";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = nextLocale === routing.defaultLocale ? "" : `/${nextLocale}`;
|
||||||
|
const finalPath = prefix + (newPath.startsWith("/") ? newPath : `/${newPath}`);
|
||||||
|
|
||||||
|
startTransition(() => {
|
||||||
|
router.push(finalPath);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleLocale}
|
||||||
|
disabled={isPending}
|
||||||
|
aria-label={t("label")}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Globe className="h-4 w-4 shrink-0" aria-hidden />
|
||||||
|
<span>{locale === "fa" ? "EN" : "FA"}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { Menu, Sparkles } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
import {
|
||||||
|
NavbarLearnDropdown,
|
||||||
|
NavbarMenuDropdown,
|
||||||
|
} from "@/components/layout/NavbarMenuDropdown";
|
||||||
|
import { NavbarMobileMenu } from "@/components/layout/NavbarMobileMenu";
|
||||||
|
import { LanguageSwitcher } from "@/components/layout/LanguageSwitcher";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
|
||||||
|
export function Navbar() {
|
||||||
|
const t = useTranslations("nav");
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMobileOpen(false);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
const closeMobile = () => setMobileOpen(false);
|
||||||
|
|
||||||
|
/** Translated nav data consumed by dropdown components */
|
||||||
|
const videoMakerNav = {
|
||||||
|
browseLabel: t("videoMakerBrowse"),
|
||||||
|
browseHref: "/templates",
|
||||||
|
items: [
|
||||||
|
{ label: t("videoMakerItems.animation"), href: "/templates?category=animation" },
|
||||||
|
{ label: t("videoMakerItems.intros"), href: "/templates?category=intros" },
|
||||||
|
{ label: t("videoMakerItems.social"), href: "/templates?category=social" },
|
||||||
|
{ label: t("videoMakerItems.slideshow"), href: "/templates?category=slideshow" },
|
||||||
|
{ label: t("videoMakerItems.ads"), href: "/templates?category=ads" },
|
||||||
|
{ label: t("videoMakerItems.music"), href: "/templates?category=music" },
|
||||||
|
{ label: t("videoMakerItems.featured"), href: "/templates?category=featured" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const imageMakerNav = {
|
||||||
|
browseLabel: t("imageMakerBrowse"),
|
||||||
|
browseHref: "/image-maker",
|
||||||
|
items: [
|
||||||
|
{ label: t("imageMakerItems.social"), href: "/image-maker?category=social" },
|
||||||
|
{ label: t("imageMakerItems.banners"), href: "/image-maker?category=banners" },
|
||||||
|
{ label: t("imageMakerItems.presentations"), href: "/image-maker?category=presentations" },
|
||||||
|
{ label: t("imageMakerItems.posters"), href: "/image-maker?category=posters" },
|
||||||
|
{ label: t("imageMakerItems.logos"), href: "/image-maker?category=logos" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const learnItems = [
|
||||||
|
{ label: t("learnItems.blog"), href: "/blog" },
|
||||||
|
{ label: t("learnItems.tutorials"), href: "/tutorials" },
|
||||||
|
{ label: t("learnItems.help"), href: "/help" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-50 w-full border-b border-gray-100 bg-white/95 backdrop-blur-sm">
|
||||||
|
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||||
|
{/* Logo */}
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
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>
|
||||||
|
<span className="font-heading text-lg font-bold text-neutral-900">
|
||||||
|
{t("brandName")}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Desktop navigation */}
|
||||||
|
<nav
|
||||||
|
className="hidden items-center gap-1 lg:flex"
|
||||||
|
aria-label="Main navigation"
|
||||||
|
>
|
||||||
|
<NavbarMenuDropdown
|
||||||
|
label={t("videoMaker")}
|
||||||
|
browseLabel={videoMakerNav.browseLabel}
|
||||||
|
browseHref={videoMakerNav.browseHref}
|
||||||
|
items={videoMakerNav.items}
|
||||||
|
/>
|
||||||
|
<NavbarMenuDropdown
|
||||||
|
label={t("imageMaker")}
|
||||||
|
browseLabel={imageMakerNav.browseLabel}
|
||||||
|
browseHref={imageMakerNav.browseHref}
|
||||||
|
items={imageMakerNav.items}
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
href="/pricing"
|
||||||
|
className="rounded-md px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
{t("pricing")}
|
||||||
|
</Link>
|
||||||
|
<NavbarLearnDropdown items={learnItems} label={t("learn")} />
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Right-side actions */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{/* Language switcher — desktop */}
|
||||||
|
<LanguageSwitcher className="hidden sm:flex" />
|
||||||
|
|
||||||
|
<Button variant="ghost" asChild className="hidden sm:inline-flex">
|
||||||
|
<Link href="/auth">{t("signIn")}</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
className="hidden bg-blue-600 text-white hover:bg-blue-700 sm:inline-flex"
|
||||||
|
>
|
||||||
|
<Link href="/auth?tab=sign-up">{t("tryForFree")}</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Mobile menu trigger */}
|
||||||
|
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10 lg:hidden"
|
||||||
|
aria-label={t("openMenuAriaLabel")}
|
||||||
|
>
|
||||||
|
<Menu className="h-5 w-5" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="right" className="flex w-full flex-col sm:max-w-sm">
|
||||||
|
<SheetHeader className="text-left">
|
||||||
|
<SheetTitle className="font-heading">{t("mobileMenuTitle")}</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<NavbarMobileMenu onNavigate={closeMobile} />
|
||||||
|
<div className="mt-auto flex flex-col gap-3 border-t border-gray-100 pb-8 pt-6">
|
||||||
|
<LanguageSwitcher className="w-full justify-center border border-gray-200" />
|
||||||
|
<Button variant="outline" size="lg" className="w-full" asChild>
|
||||||
|
<Link href="/auth" onClick={closeMobile}>
|
||||||
|
{t("signIn")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="w-full bg-blue-600 text-white hover:bg-blue-700"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link href="/auth?tab=sign-up" onClick={closeMobile}>
|
||||||
|
{t("tryForFree")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
{/* Mobile CTA (outside sheet) */}
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
size="sm"
|
||||||
|
className="bg-blue-600 text-white hover:bg-blue-700 lg:hidden"
|
||||||
|
>
|
||||||
|
<Link href="/auth?tab=sign-up">{t("tryForFree")}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ChevronDown, LayoutGrid } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import type { NavbarMenuLink } from "@/lib/navbar-menu-data";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface NavbarMenuDropdownProps {
|
||||||
|
label: string;
|
||||||
|
browseLabel: string;
|
||||||
|
browseHref: string;
|
||||||
|
items: readonly NavbarMenuLink[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerClassName =
|
||||||
|
"flex items-center gap-1 rounded-md px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2";
|
||||||
|
|
||||||
|
const panelClassName =
|
||||||
|
"min-w-[220px] rounded-xl border border-gray-100 bg-white p-2 shadow-xl";
|
||||||
|
|
||||||
|
export function NavbarMenuDropdown({
|
||||||
|
label,
|
||||||
|
browseLabel,
|
||||||
|
browseHref,
|
||||||
|
items,
|
||||||
|
}: NavbarMenuDropdownProps) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger className={triggerClassName}>
|
||||||
|
{label}
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 text-gray-500" aria-hidden />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className={panelClassName}>
|
||||||
|
<DropdownMenuItem asChild className="cursor-pointer p-0 focus:bg-transparent">
|
||||||
|
<Link
|
||||||
|
href={browseHref}
|
||||||
|
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold text-blue-600 hover:bg-blue-50"
|
||||||
|
>
|
||||||
|
<LayoutGrid className="h-4 w-4 shrink-0" aria-hidden />
|
||||||
|
{browseLabel}
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator className="mx-2 bg-gray-100" />
|
||||||
|
{items.map((item) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={item.href}
|
||||||
|
asChild
|
||||||
|
className="cursor-pointer rounded-lg px-3 py-2 text-sm text-gray-700 focus:bg-gray-50"
|
||||||
|
>
|
||||||
|
<Link href={item.href}>{item.label}</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavbarLearnDropdownProps {
|
||||||
|
items: readonly NavbarMenuLink[];
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NavbarLearnDropdown({ items, label = "Learn" }: NavbarLearnDropdownProps) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger className={triggerClassName}>
|
||||||
|
{label}
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 text-gray-500" aria-hidden />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className={cn(panelClassName, "min-w-[180px]")}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={item.href}
|
||||||
|
asChild
|
||||||
|
className="cursor-pointer rounded-lg px-3 py-2 text-sm text-gray-700 focus:bg-gray-50"
|
||||||
|
>
|
||||||
|
<Link href={item.href}>{item.label}</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { LayoutGrid } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
IMAGE_MAKER_NAV,
|
||||||
|
LEARN_NAV_ITEMS,
|
||||||
|
VIDEO_MAKER_NAV,
|
||||||
|
} from "@/lib/navbar-menu-data";
|
||||||
|
|
||||||
|
interface NavbarMobileMenuProps {
|
||||||
|
onNavigate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkClass =
|
||||||
|
"flex min-h-11 items-center rounded-lg px-3 text-sm font-medium text-gray-700 hover:bg-gray-50 hover:text-gray-900";
|
||||||
|
|
||||||
|
export function NavbarMobileMenu({ onNavigate }: NavbarMobileMenuProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col gap-6 overflow-y-auto">
|
||||||
|
<section>
|
||||||
|
<p className="px-3 text-xs font-semibold uppercase tracking-wide text-gray-400">
|
||||||
|
Video Maker
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={VIDEO_MAKER_NAV.browseHref}
|
||||||
|
onClick={onNavigate}
|
||||||
|
className="mt-1 flex min-h-11 items-center gap-2 rounded-lg px-3 text-sm font-semibold text-blue-600 hover:bg-blue-50"
|
||||||
|
>
|
||||||
|
<LayoutGrid className="h-4 w-4" aria-hidden />
|
||||||
|
{VIDEO_MAKER_NAV.browseLabel}
|
||||||
|
</Link>
|
||||||
|
<ul className="mt-1 space-y-0.5">
|
||||||
|
{VIDEO_MAKER_NAV.items.map((item) => (
|
||||||
|
<li key={item.href}>
|
||||||
|
<Link href={item.href} onClick={onNavigate} className={linkClass}>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<p className="px-3 text-xs font-semibold uppercase tracking-wide text-gray-400">
|
||||||
|
Image Maker
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={IMAGE_MAKER_NAV.browseHref}
|
||||||
|
onClick={onNavigate}
|
||||||
|
className="mt-1 flex min-h-11 items-center gap-2 rounded-lg px-3 text-sm font-semibold text-blue-600 hover:bg-blue-50"
|
||||||
|
>
|
||||||
|
<LayoutGrid className="h-4 w-4" aria-hidden />
|
||||||
|
{IMAGE_MAKER_NAV.browseLabel}
|
||||||
|
</Link>
|
||||||
|
<ul className="mt-1 space-y-0.5">
|
||||||
|
{IMAGE_MAKER_NAV.items.map((item) => (
|
||||||
|
<li key={item.href}>
|
||||||
|
<Link href={item.href} onClick={onNavigate} className={linkClass}>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<Link href="/pricing" onClick={onNavigate} className={linkClass}>
|
||||||
|
Pricing
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<p className="px-3 text-xs font-semibold uppercase tracking-wide text-gray-400">
|
||||||
|
Learn
|
||||||
|
</p>
|
||||||
|
<ul className="mt-1 space-y-0.5">
|
||||||
|
{LEARN_NAV_ITEMS.map((item) => (
|
||||||
|
<li key={item.href}>
|
||||||
|
<Link href={item.href} onClick={onNavigate} className={linkClass}>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
import { Footer } from "@/components/layout/Footer";
|
||||||
|
import { Navbar } from "@/components/layout/Navbar";
|
||||||
|
|
||||||
|
interface SiteChromeProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SiteChrome({ children }: SiteChromeProps) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isAppShell =
|
||||||
|
pathname.startsWith("/dashboard") || pathname.startsWith("/studio");
|
||||||
|
|
||||||
|
if (isAppShell) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Navbar />
|
||||||
|
{children}
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import { SectionReveal } from "./SectionReveal";
|
||||||
|
|
||||||
|
export interface FAQProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FAQ_IDS = ["q0","q1","q2","q3","q4","q5","q6","q7"] as const;
|
||||||
|
|
||||||
|
export function FAQ({ className }: FAQProps) {
|
||||||
|
const t = useTranslations("faq");
|
||||||
|
|
||||||
|
const items = FAQ_IDS.map((id) => ({
|
||||||
|
id,
|
||||||
|
question: t(id),
|
||||||
|
answer: t(id.replace("q", "a") as Parameters<typeof t>[0]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const columns = [items.slice(0, 4), items.slice(4, 8)];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={cn("w-full bg-white py-20 sm:py-28", className)}>
|
||||||
|
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
|
<SectionReveal>
|
||||||
|
<h2 className="text-center font-heading text-3xl font-bold tracking-tight text-neutral-900 sm:text-4xl">
|
||||||
|
{t("heading")}
|
||||||
|
</h2>
|
||||||
|
<p className="mx-auto mt-4 max-w-2xl text-center text-neutral-600">
|
||||||
|
{t("subtitle")}
|
||||||
|
</p>
|
||||||
|
</SectionReveal>
|
||||||
|
|
||||||
|
<SectionReveal className="mt-12 grid grid-cols-1 gap-8 lg:grid-cols-2 lg:gap-12">
|
||||||
|
{columns.map((column, columnIndex) => (
|
||||||
|
<Accordion
|
||||||
|
key={columnIndex}
|
||||||
|
type="single"
|
||||||
|
collapsible
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{column.map((item) => (
|
||||||
|
<AccordionItem key={item.id} value={item.id}>
|
||||||
|
<AccordionTrigger>{item.question}</AccordionTrigger>
|
||||||
|
<AccordionContent>{item.answer}</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
))}
|
||||||
|
</SectionReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { motion, type Variants } from "framer-motion";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import { HeroBackgroundBlobs } from "./HeroBackgroundBlobs";
|
||||||
|
import { HeroPreviewCards } from "./HeroPreviewCards";
|
||||||
|
|
||||||
|
export interface HeroProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fadeUp: Variants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
show: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: { duration: 0.4, ease: "easeOut" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Hero({ className }: HeroProps) {
|
||||||
|
const t = useTranslations("hero");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={cn(
|
||||||
|
"relative w-full overflow-hidden bg-white",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<HeroBackgroundBlobs />
|
||||||
|
|
||||||
|
<div className="relative mx-auto max-w-7xl px-4 pb-16 pt-14 sm:px-6 sm:pb-20 sm:pt-20 lg:px-8 lg:pt-24">
|
||||||
|
<motion.div
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="show"
|
||||||
|
viewport={{ once: true, margin: "-40px" }}
|
||||||
|
variants={{
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
show: { opacity: 1, transition: { staggerChildren: 0.1 } },
|
||||||
|
}}
|
||||||
|
className="mx-auto max-w-4xl text-center"
|
||||||
|
>
|
||||||
|
<motion.div variants={fadeUp}>
|
||||||
|
<span className="inline-flex items-center rounded-full border border-violet-100 bg-white/80 px-4 py-1.5 text-sm font-medium text-neutral-600 shadow-sm backdrop-blur-sm">
|
||||||
|
<span className="text-amber-500" aria-hidden>
|
||||||
|
★
|
||||||
|
</span>
|
||||||
|
<span className="ms-1.5">{t("badge")}</span>
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.h1
|
||||||
|
variants={fadeUp}
|
||||||
|
className="mt-6 font-heading text-4xl font-bold leading-[1.1] tracking-tight text-neutral-900 sm:mt-8 sm:text-5xl lg:text-[3.25rem]"
|
||||||
|
>
|
||||||
|
{t.rich("title", {
|
||||||
|
highlight: (chunks) => (
|
||||||
|
<span className="bg-gradient-to-r from-blue-600 via-violet-500 to-blue-500 bg-clip-text text-transparent">
|
||||||
|
{chunks}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
</motion.h1>
|
||||||
|
|
||||||
|
<motion.p
|
||||||
|
variants={fadeUp}
|
||||||
|
className="mx-auto mt-5 max-w-2xl text-base leading-relaxed text-neutral-600 sm:text-lg"
|
||||||
|
>
|
||||||
|
{t("description")}
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
variants={fadeUp}
|
||||||
|
className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row sm:gap-4"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="h-12 min-w-[11rem] rounded-lg bg-gradient-to-r from-violet-600 to-rf-blue px-8 text-base font-semibold text-white shadow-md hover:from-violet-700 hover:to-rf-blue/90"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link href="/auth?tab=sign-up">{t("cta")}</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
className="h-12 min-w-[11rem] rounded-lg border-2 border-rf-blue bg-white px-8 text-base font-semibold text-rf-blue hover:bg-rf-blue-light"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link href="#templates">{t("browse")}</Link>
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<h2 className="sr-only">{t("previewsLabel")}</h2>
|
||||||
|
<HeroPreviewCards />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const blobs = [
|
||||||
|
{
|
||||||
|
className: "left-[0%] top-[5%] h-96 w-96 bg-violet-200",
|
||||||
|
animate: { x: [0, 24, 0], y: [0, 16, 0], scale: [1, 1.05, 1] },
|
||||||
|
duration: 14,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
className: "right-[0%] top-[8%] h-80 w-80 bg-sky-200",
|
||||||
|
animate: { x: [0, -20, 0], y: [0, 24, 0], scale: [1, 1.08, 1] },
|
||||||
|
duration: 16,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
className: "bottom-[20%] left-[30%] h-72 w-72 bg-rose-100",
|
||||||
|
animate: { x: [0, 16, 0], y: [0, -20, 0], scale: [1, 1.06, 1] },
|
||||||
|
duration: 12,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
className: "bottom-[10%] right-[25%] h-64 w-64 bg-amber-100",
|
||||||
|
animate: { x: [0, -12, 0], y: [0, 12, 0], scale: [1, 1.04, 1] },
|
||||||
|
duration: 18,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function HeroBackgroundBlobs() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-violet-50/90 via-white/80 to-white" />
|
||||||
|
{blobs.map((blob, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
"absolute rounded-full opacity-40 blur-3xl",
|
||||||
|
blob.className
|
||||||
|
)}
|
||||||
|
animate={blob.animate}
|
||||||
|
transition={{
|
||||||
|
duration: blob.duration,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { motion, type Variants } from "framer-motion";
|
||||||
|
|
||||||
|
import { VideoPlayOverlay } from "@/components/sections/VideoPlayOverlay";
|
||||||
|
import { getHeroPreviewVideoSrc } from "@/lib/template-preview-media";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const previewTemplates = [
|
||||||
|
{ id: "hero-3d", title: "Factory of 3D Animations" },
|
||||||
|
{ id: "hero-whiteboard", title: "Whiteboard Animation Toolkit" },
|
||||||
|
{ id: "hero-explainer", title: "3D Explainer Video Toolkit" },
|
||||||
|
{ id: "hero-trendy", title: "Trendy Explainer Toolkit" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const containerVariants: Variants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
show: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: { staggerChildren: 0.08, delayChildren: 0.1 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardVariants: Variants = {
|
||||||
|
hidden: { opacity: 0, y: 24 },
|
||||||
|
show: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: { duration: 0.4, ease: "easeOut" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface HeroVideoThumbProps {
|
||||||
|
videoSrc: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeroVideoThumb({ videoSrc, label }: HeroVideoThumbProps) {
|
||||||
|
return (
|
||||||
|
<div className="group/thumb relative aspect-[4/3] overflow-hidden rounded-xl border border-neutral-200/80 bg-neutral-100 shadow-sm transition-shadow duration-300 hover:shadow-md">
|
||||||
|
<video
|
||||||
|
src={videoSrc}
|
||||||
|
autoPlay
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
|
className="h-full w-full object-cover transition-transform duration-500 ease-out group-hover/thumb:scale-[1.02]"
|
||||||
|
aria-label={`${label} preview`}
|
||||||
|
/>
|
||||||
|
<VideoPlayOverlay
|
||||||
|
size="lg"
|
||||||
|
className="opacity-100 transition-opacity duration-300 ease-out group-hover/thumb:opacity-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeroPreviewCards() {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="show"
|
||||||
|
viewport={{ once: true, margin: "-40px" }}
|
||||||
|
className="mx-auto mt-14 w-full max-w-7xl sm:mt-16"
|
||||||
|
>
|
||||||
|
<p className="text-center font-heading text-xl font-bold tracking-tight text-neutral-900 sm:text-2xl">
|
||||||
|
Made by world-class motion designers
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-8 grid grid-cols-2 gap-4 sm:gap-5 lg:grid-cols-4 lg:gap-6">
|
||||||
|
{previewTemplates.map((template, index) => (
|
||||||
|
<motion.div key={template.id} variants={cardVariants}>
|
||||||
|
<Link
|
||||||
|
href="/templates"
|
||||||
|
className={cn(
|
||||||
|
"group block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rf-blue focus-visible:ring-offset-2",
|
||||||
|
"transition-transform duration-300 hover:-translate-y-0.5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<HeroVideoThumb
|
||||||
|
videoSrc={getHeroPreviewVideoSrc(index)}
|
||||||
|
label={template.title}
|
||||||
|
/>
|
||||||
|
<p className="mt-3 text-center font-heading text-sm font-semibold text-neutral-900 sm:text-[15px]">
|
||||||
|
{template.title}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { LayoutTemplate, Share2, Wand2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import { SectionReveal } from "./SectionReveal";
|
||||||
|
import { HowItWorksConnector } from "./HowItWorksConnector";
|
||||||
|
import { HowItWorksStep } from "./HowItWorksStep";
|
||||||
|
|
||||||
|
export interface HowItWorksProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP_ICONS = [LayoutTemplate, Wand2, Share2];
|
||||||
|
const STEP_CLASSES = [
|
||||||
|
"bg-gradient-to-br from-primary-100 to-primary-50 border-primary-200 text-primary-600",
|
||||||
|
"bg-gradient-to-br from-violet-100 to-violet-50 border-violet-200 text-violet-600",
|
||||||
|
"bg-gradient-to-br from-neutral-100 to-neutral-50 border-neutral-200 text-neutral-600",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function HowItWorks({ className }: HowItWorksProps) {
|
||||||
|
const t = useTranslations("howItWorks");
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ number: 1, title: t("step1Title"), description: t("step1Desc"), icon: STEP_ICONS[0], previewClassName: STEP_CLASSES[0] },
|
||||||
|
{ number: 2, title: t("step2Title"), description: t("step2Desc"), icon: STEP_ICONS[1], previewClassName: STEP_CLASSES[1] },
|
||||||
|
{ number: 3, title: t("step3Title"), description: t("step3Desc"), icon: STEP_ICONS[2], previewClassName: STEP_CLASSES[2] },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={cn("w-full bg-neutral-50 py-20 sm:py-28", className)}>
|
||||||
|
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
|
<SectionReveal className="text-center">
|
||||||
|
<h2 className="font-heading text-3xl font-bold tracking-tight text-neutral-900 sm:text-4xl">
|
||||||
|
{t("heading")}
|
||||||
|
</h2>
|
||||||
|
<p className="mx-auto mt-4 max-w-2xl text-neutral-600">
|
||||||
|
{t("subtitle")}
|
||||||
|
</p>
|
||||||
|
</SectionReveal>
|
||||||
|
|
||||||
|
<div className="mx-auto mt-16 max-w-5xl">
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<div key={step.number}>
|
||||||
|
<HowItWorksStep
|
||||||
|
number={step.number}
|
||||||
|
title={step.title}
|
||||||
|
description={step.description}
|
||||||
|
icon={step.icon}
|
||||||
|
previewClassName={step.previewClassName}
|
||||||
|
reversed={index % 2 === 1}
|
||||||
|
/>
|
||||||
|
{index < steps.length - 1 && <HowItWorksConnector />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { ArrowDown } from "lucide-react";
|
||||||
|
|
||||||
|
export function HowItWorksConnector() {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scaleY: 0 }}
|
||||||
|
whileInView={{ opacity: 1, scaleY: 1 }}
|
||||||
|
viewport={{ once: true, margin: "-40px" }}
|
||||||
|
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||||
|
className="flex flex-col items-center py-6 sm:py-8"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<div className="h-10 w-px bg-gradient-to-b from-primary-300 to-primary-500" />
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-full border border-primary-200 bg-primary-50 text-primary-600 shadow-sm">
|
||||||
|
<ArrowDown className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="h-10 w-px bg-gradient-to-b from-primary-500 to-primary-300" />
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface HowItWorksStepProps {
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
previewClassName: string;
|
||||||
|
reversed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HowItWorksStep({
|
||||||
|
number,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon: Icon,
|
||||||
|
previewClassName,
|
||||||
|
reversed = false,
|
||||||
|
}: HowItWorksStepProps) {
|
||||||
|
const slideFrom = reversed ? 48 : -48;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid items-center gap-8 lg:grid-cols-2 lg:gap-16",
|
||||||
|
reversed && "lg:[&>*:first-child]:order-2 lg:[&>*:last-child]:order-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: slideFrom }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-80px" }}
|
||||||
|
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||||
|
className="flex gap-5"
|
||||||
|
>
|
||||||
|
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-primary-600 font-heading text-lg font-bold text-white shadow-sm">
|
||||||
|
{number}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-primary-50 text-primary-600">
|
||||||
|
<Icon className="h-5 w-5" aria-hidden />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-heading text-xl font-bold text-neutral-900 sm:text-2xl">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-3 text-base leading-relaxed text-neutral-600">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -slideFrom }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-80px" }}
|
||||||
|
transition={{ duration: 0.4, ease: "easeOut", delay: 0.08 }}
|
||||||
|
className={cn(
|
||||||
|
"flex aspect-[4/3] items-center justify-center rounded-xl border shadow-sm",
|
||||||
|
previewClassName
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<Icon className="h-20 w-20 opacity-30" />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user