Compare commits
10 Commits
2adaf57f10
...
3fc7bf2b97
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fc7bf2b97 | |||
| bcc69f0a2e | |||
| 12773e125a | |||
| d7743a6fbe | |||
| ee421ccc68 | |||
| 541e935418 | |||
| 1b9a92a790 | |||
| a076c4911f | |||
| 9555044485 | |||
| 903306c7cf |
@@ -6,6 +6,9 @@ API_GATEWAY_URL=http://localhost:8088
|
|||||||
NEXT_PUBLIC_API_URL=http://localhost:8088/v1
|
NEXT_PUBLIC_API_URL=http://localhost:8088/v1
|
||||||
# Tenant the public site authenticates against (Identity service).
|
# Tenant the public site authenticates against (Identity service).
|
||||||
NEXT_PUBLIC_TENANT_SLUG=flatrender
|
NEXT_PUBLIC_TENANT_SLUG=flatrender
|
||||||
|
# Mark auth cookies Secure (HTTPS only). Leave false for plain-HTTP/local; set true
|
||||||
|
# when served over TLS, or the browser will drop the session cookies.
|
||||||
|
AUTH_COOKIE_SECURE=false
|
||||||
|
|
||||||
# FlatRender Admin API (LEGACY V1 — being replaced by the gateway above)
|
# FlatRender Admin API (LEGACY V1 — being replaced by the gateway above)
|
||||||
# Run the admin-api service at D:\Projects\flatrender-admin\admin-api
|
# Run the admin-api service at D:\Projects\flatrender-admin\admin-api
|
||||||
|
|||||||
+17
-7
@@ -26,6 +26,12 @@ CORS_ORIGIN=http://localhost:3000
|
|||||||
# The only backend port exposed to the host. Change if 8080 is taken locally.
|
# The only backend port exposed to the host. Change if 8080 is taken locally.
|
||||||
GATEWAY_PORT=8080
|
GATEWAY_PORT=8080
|
||||||
|
|
||||||
|
# ── Frontend public vars (baked into the Next.js image at build time) ────────
|
||||||
|
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||||
|
# Browser-facing gateway URL (host port). Must match GATEWAY_PORT above.
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:8080/v1
|
||||||
|
NEXT_PUBLIC_TENANT_SLUG=flatrender
|
||||||
|
|
||||||
# ── ZarinPal (Iranian payment gateway) ───────────────────────────────────────
|
# ── ZarinPal (Iranian payment gateway) ───────────────────────────────────────
|
||||||
# Get your merchant ID from https://www.zarinpal.com/
|
# Get your merchant ID from https://www.zarinpal.com/
|
||||||
ZARINPAL_MERCHANT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
ZARINPAL_MERCHANT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||||
@@ -49,12 +55,16 @@ TARA_CALLBACK_URL=https://yourdomain.com/v1/payments/callback/tara
|
|||||||
# ── Stripe (international payment gateway) ───────────────────────────────────
|
# ── Stripe (international payment gateway) ───────────────────────────────────
|
||||||
# Get keys from https://dashboard.stripe.com/apikeys
|
# Get keys from https://dashboard.stripe.com/apikeys
|
||||||
STRIPE_SECRET_KEY=sk_test_...
|
STRIPE_SECRET_KEY=sk_test_...
|
||||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
|
||||||
STRIPE_PUBLISHABLE_KEY=pk_test_...
|
STRIPE_PUBLISHABLE_KEY=pk_test_...
|
||||||
|
|
||||||
# ── Next.js frontend (NEXT_PUBLIC_* baked at build time) ─────────────────────
|
# ── Caddy TLS reverse proxy ───────────────────────────────────────────────────
|
||||||
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
# Public-facing domains (Let's Encrypt will provision certs automatically).
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
|
# Leave as localhost for local dev (Caddy uses self-signed cert).
|
||||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
|
DOMAIN=flatrender.io
|
||||||
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
API_DOMAIN=api.flatrender.io
|
||||||
SUPABASE_SERVICE_ROLE_KEY=eyJ...
|
STORAGE_DOMAIN=storage.flatrender.io
|
||||||
|
ACME_EMAIL=admin@flatrender.io
|
||||||
|
|
||||||
|
# ── MinIO templates bucket ────────────────────────────────────────────────────
|
||||||
|
# Bucket where .aep template files are stored (uploaded via admin panel).
|
||||||
|
MINIO_TEMPLATES_BUCKET=flatrender-templates
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# FlatRender V2 — Caddy reverse proxy
|
||||||
|
#
|
||||||
|
# Domains are injected via environment variables so this file is environment-agnostic.
|
||||||
|
# Set in .env.v2:
|
||||||
|
# DOMAIN e.g. flatrender.io (→ https://flatrender.io)
|
||||||
|
# API_DOMAIN e.g. api.flatrender.io (→ https://api.flatrender.io)
|
||||||
|
# STORAGE_DOMAIN e.g. storage.flatrender.io (→ https://storage.flatrender.io)
|
||||||
|
#
|
||||||
|
# Caddy auto-provisions Let's Encrypt TLS for all three. For local dev without
|
||||||
|
# real domains, replace with http:// blocks and remove the ACME config.
|
||||||
|
|
||||||
|
{env.DOMAIN} {
|
||||||
|
# Frontend (Next.js standalone, port 3000 inside Docker)
|
||||||
|
reverse_proxy frontend:3000
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-Frame-Options "SAMEORIGIN"
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
-Server
|
||||||
|
}
|
||||||
|
|
||||||
|
encode gzip
|
||||||
|
}
|
||||||
|
|
||||||
|
{env.API_DOMAIN} {
|
||||||
|
# V2 API gateway (port 8080 inside Docker)
|
||||||
|
reverse_proxy gateway:8080
|
||||||
|
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
-Server
|
||||||
|
}
|
||||||
|
|
||||||
|
# Allow large body for file uploads routed through the gateway
|
||||||
|
request_body {
|
||||||
|
max_size 512MB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{env.STORAGE_DOMAIN} {
|
||||||
|
# MinIO S3 API (port 9000 inside Docker) — used for presigned URL downloads
|
||||||
|
reverse_proxy minio:9000
|
||||||
|
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
-Server
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pre-flight (CORS) passthrough — MinIO handles its own CORS headers
|
||||||
|
@options method OPTIONS
|
||||||
|
respond @options 204
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- CONTENT SCHEMA — Part 17: AI settings (per-tenant OpenAI config)
|
||||||
|
-- Stores the OpenAI (or OpenAI-compatible) API credentials used by the
|
||||||
|
-- AI SEO content generator. One row per tenant; api_key is stored as-is
|
||||||
|
-- (self-hosted) and never returned in full to clients (masked in the API).
|
||||||
|
-- base_url is configurable so deployments behind a proxy / in restricted
|
||||||
|
-- networks can point at a reachable OpenAI-compatible endpoint.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
SET search_path TO content, public;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_settings (
|
||||||
|
tenant_id UUID PRIMARY KEY,
|
||||||
|
provider TEXT NOT NULL DEFAULT 'openai',
|
||||||
|
api_key TEXT,
|
||||||
|
base_url TEXT NOT NULL DEFAULT 'https://api.openai.com/v1',
|
||||||
|
model TEXT NOT NULL DEFAULT 'gpt-4o-mini',
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
+38
-10
@@ -107,7 +107,7 @@ services:
|
|||||||
ASPNETCORE_HTTP_PORTS: "8080"
|
ASPNETCORE_HTTP_PORTS: "8080"
|
||||||
ConnectionStrings__Postgres: "Host=postgres;Port=5432;Database=flatrender;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres};Search Path=content,public;Pooling=true"
|
ConnectionStrings__Postgres: "Host=postgres;Port=5432;Database=flatrender;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres};Search Path=content,public;Pooling=true"
|
||||||
Jwt__Secret: "${JWT_SECRET}"
|
Jwt__Secret: "${JWT_SECRET}"
|
||||||
Jwt__Issuer: "flatrender"
|
Jwt__Issuer: "flatrender-identity"
|
||||||
Jwt__Audience: "flatrender"
|
Jwt__Audience: "flatrender"
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
@@ -159,7 +159,7 @@ services:
|
|||||||
ASPNETCORE_HTTP_PORTS: "8080"
|
ASPNETCORE_HTTP_PORTS: "8080"
|
||||||
ConnectionStrings__Default: "Host=postgres;Port=5432;Database=flatrender;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres};Search Path=studio,public;Pooling=true"
|
ConnectionStrings__Default: "Host=postgres;Port=5432;Database=flatrender;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres};Search Path=studio,public;Pooling=true"
|
||||||
Jwt__Key: "${JWT_SECRET}"
|
Jwt__Key: "${JWT_SECRET}"
|
||||||
Jwt__Issuer: "flatrender"
|
Jwt__Issuer: "flatrender-identity"
|
||||||
Jwt__Audience: "flatrender"
|
Jwt__Audience: "flatrender"
|
||||||
Cors__Origins__0: "${CORS_ORIGIN:-http://localhost:3000}"
|
Cors__Origins__0: "${CORS_ORIGIN:-http://localhost:3000}"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -272,12 +272,9 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
args:
|
args:
|
||||||
NEXT_PUBLIC_SUPABASE_URL: "${NEXT_PUBLIC_SUPABASE_URL:-}"
|
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: "${NEXT_PUBLIC_SUPABASE_ANON_KEY:-}"
|
|
||||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: "${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:-}"
|
|
||||||
NEXT_PUBLIC_SITE_URL: "${NEXT_PUBLIC_SITE_URL:-http://localhost:3000}"
|
NEXT_PUBLIC_SITE_URL: "${NEXT_PUBLIC_SITE_URL:-http://localhost:3000}"
|
||||||
# V2 gateway: browser-facing base (host port) baked in at build time.
|
# V2 gateway: browser-facing base (host port) baked in at build time.
|
||||||
NEXT_PUBLIC_API_URL: "${NEXT_PUBLIC_API_URL:-http://localhost:${GATEWAY_PORT:-8088}/v1}"
|
NEXT_PUBLIC_API_URL: "${NEXT_PUBLIC_API_URL:-http://localhost:${GATEWAY_PORT:-8080}/v1}"
|
||||||
NEXT_PUBLIC_TENANT_SLUG: "${NEXT_PUBLIC_TENANT_SLUG:-flatrender}"
|
NEXT_PUBLIC_TENANT_SLUG: "${NEXT_PUBLIC_TENANT_SLUG:-flatrender}"
|
||||||
container_name: fr2-frontend
|
container_name: fr2-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -289,10 +286,6 @@ services:
|
|||||||
HOSTNAME: "0.0.0.0"
|
HOSTNAME: "0.0.0.0"
|
||||||
# Server-side: Next route handlers reach the gateway over the internal network.
|
# Server-side: Next route handlers reach the gateway over the internal network.
|
||||||
API_GATEWAY_URL: "http://gateway:8080"
|
API_GATEWAY_URL: "http://gateway:8080"
|
||||||
# Server-side secrets (not baked into image)
|
|
||||||
SUPABASE_SERVICE_ROLE_KEY: "${SUPABASE_SERVICE_ROLE_KEY:-}"
|
|
||||||
STRIPE_SECRET_KEY: "${STRIPE_SECRET_KEY:-}"
|
|
||||||
STRIPE_WEBHOOK_SECRET: "${STRIPE_WEBHOOK_SECRET:-}"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
gateway:
|
gateway:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -303,6 +296,41 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
|
|
||||||
|
# ── Caddy (TLS reverse proxy) ───────────────────────────────────────────────
|
||||||
|
# Handles Let's Encrypt certificates and terminates HTTPS for all three
|
||||||
|
# public domains: frontend, API gateway, and MinIO storage.
|
||||||
|
#
|
||||||
|
# Required .env.v2 vars: DOMAIN, API_DOMAIN, STORAGE_DOMAIN
|
||||||
|
# Set ACME_EMAIL to a real address so Let's Encrypt can contact you.
|
||||||
|
#
|
||||||
|
# For local dev (no real domain), comment out this block and access
|
||||||
|
# services directly on their host ports (:3000, :8088, :9000).
|
||||||
|
caddy:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
container_name: fr2-caddy
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
- "443:443/udp" # HTTP/3 QUIC
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- caddy_data:/data
|
||||||
|
- caddy_config:/config
|
||||||
|
environment:
|
||||||
|
DOMAIN: "${DOMAIN:-localhost}"
|
||||||
|
API_DOMAIN: "${API_DOMAIN:-api.localhost}"
|
||||||
|
STORAGE_DOMAIN: "${STORAGE_DOMAIN:-storage.localhost}"
|
||||||
|
ACME_EMAIL: "${ACME_EMAIL:-admin@example.com}"
|
||||||
|
depends_on:
|
||||||
|
- frontend
|
||||||
|
- gateway
|
||||||
|
- minio
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
caddy_data:
|
||||||
|
caddy_config:
|
||||||
miniodata:
|
miniodata:
|
||||||
|
|||||||
@@ -305,5 +305,905 @@
|
|||||||
"all": "All",
|
"all": "All",
|
||||||
"popular": "Popular",
|
"popular": "Popular",
|
||||||
"new": "New"
|
"new": "New"
|
||||||
|
},
|
||||||
|
"auto": {
|
||||||
|
"appAdminLayout": {
|
||||||
|
"brand": "FlatRender Admin",
|
||||||
|
"nodes": "Nodes",
|
||||||
|
"renderQueue": "Render Queue",
|
||||||
|
"backToDashboard": "← Back to Dashboard",
|
||||||
|
"aiContent": "AI Content",
|
||||||
|
"categories": "Categories",
|
||||||
|
"tags": "Tags",
|
||||||
|
"fonts": "Fonts",
|
||||||
|
"blogs": "Blog",
|
||||||
|
"slides": "Slides",
|
||||||
|
"users": "Users",
|
||||||
|
"plans": "Plans"
|
||||||
|
},
|
||||||
|
"appAdminNodesPage": {
|
||||||
|
"title": "Render Nodes",
|
||||||
|
"registered": "{count, plural, one {# node registered} other {# nodes registered}}"
|
||||||
|
},
|
||||||
|
"appAdminRendersPage": {
|
||||||
|
"title": "Render Queue",
|
||||||
|
"totalJobs": "{total} total jobs",
|
||||||
|
"filterAll": "All",
|
||||||
|
"stepQueued": "Queued",
|
||||||
|
"stepPreparing": "Preparing",
|
||||||
|
"stepRendering": "Rendering",
|
||||||
|
"stepUploading": "Uploading",
|
||||||
|
"stepDone": "Done",
|
||||||
|
"stepFailed": "Failed",
|
||||||
|
"stepCancelled": "Cancelled"
|
||||||
|
},
|
||||||
|
"appAuthPage": {
|
||||||
|
"metaTitle": "Sign In",
|
||||||
|
"metaDescription": "Sign in or create your CreatorStudio account.",
|
||||||
|
"loading": "Loading..."
|
||||||
|
},
|
||||||
|
"appDashboardSettingsPage": {
|
||||||
|
"title": "Settings",
|
||||||
|
"subtitle": "Manage your account, security, and notification preferences.",
|
||||||
|
"dangerZoneTitle": "Danger zone",
|
||||||
|
"dangerZoneDescription": "Permanently delete your account and all your projects. This cannot be undone.",
|
||||||
|
"deleteAccount": "Delete account"
|
||||||
|
},
|
||||||
|
"appError": {
|
||||||
|
"title": "Something went wrong",
|
||||||
|
"description": "An unexpected error occurred. Try reloading the page.",
|
||||||
|
"reloadButton": "Reload page"
|
||||||
|
},
|
||||||
|
"appNotFound": {
|
||||||
|
"title": "Page not found",
|
||||||
|
"description": "The page you are looking for does not exist or may have been moved.",
|
||||||
|
"goHome": "Go home"
|
||||||
|
},
|
||||||
|
"appStudioImageProjectIdPage": {
|
||||||
|
"loadingEditor": "Loading editor…"
|
||||||
|
},
|
||||||
|
"appStudioTrimmerPage": {
|
||||||
|
"back": "Back",
|
||||||
|
"title": "Video Trimmer & Cropper",
|
||||||
|
"ffmpegLoadError": "Failed to load FFmpeg. Check your connection and try again.",
|
||||||
|
"processingError": "Processing failed. Try a shorter clip or different format."
|
||||||
|
},
|
||||||
|
"appStudioVideoProjectIdPage": {
|
||||||
|
"loading": "Loading studio…"
|
||||||
|
},
|
||||||
|
"appVideoMakerPage": {
|
||||||
|
"metaTitle": "AI Video Maker",
|
||||||
|
"metaDescription": "Create stunning videos in minutes with AI scripts, auto-subtitles, 500+ templates, and 1-click export."
|
||||||
|
},
|
||||||
|
"componentsAdminNodesTable": {
|
||||||
|
"emptyState": "No nodes registered. Start the node agent on a render machine to see it here.",
|
||||||
|
"colNode": "Node",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"colSlots": "Slots",
|
||||||
|
"colHeartbeat": "Heartbeat",
|
||||||
|
"colActiveJob": "Active Job",
|
||||||
|
"colTags": "Tags",
|
||||||
|
"colActions": "Actions",
|
||||||
|
"actionDrain": "Drain",
|
||||||
|
"actionRelease": "Release"
|
||||||
|
},
|
||||||
|
"componentsAdminRenderQueueTable": {
|
||||||
|
"emptyState": "No render jobs found for the selected filter.",
|
||||||
|
"colJobId": "Job ID",
|
||||||
|
"colProject": "Project",
|
||||||
|
"colStep": "Step",
|
||||||
|
"colProgress": "Progress",
|
||||||
|
"colQuality": "Quality",
|
||||||
|
"colNode": "Node",
|
||||||
|
"colCreated": "Created",
|
||||||
|
"colActions": "Actions",
|
||||||
|
"actionRetry": "Retry",
|
||||||
|
"actionCancel": "Cancel"
|
||||||
|
},
|
||||||
|
"componentsAuthAuthPageContent": {
|
||||||
|
"genericError": "Something went wrong. Please try again.",
|
||||||
|
"accountCreatedVerify": "Account created. Check your email to verify, then sign in.",
|
||||||
|
"accountCreatedSignIn": "Account created. Please sign in.",
|
||||||
|
"networkError": "Network error. Please try again.",
|
||||||
|
"resetCodeSent": "If that email is registered, we sent a reset code.",
|
||||||
|
"invalidCode": "Invalid or expired code.",
|
||||||
|
"passwordUpdated": "Password updated. You can now sign in.",
|
||||||
|
"checkingAuth": "Checking authentication...",
|
||||||
|
"resetTitle": "Reset your password",
|
||||||
|
"enterCodeTitle": "Enter reset code",
|
||||||
|
"resetSubtitle": "We'll send a one-time code to your email.",
|
||||||
|
"enterCodeSubtitle": "Check your email for the code sent to {email}",
|
||||||
|
"emailAddressLabel": "Email address",
|
||||||
|
"sendResetCode": "Send reset code",
|
||||||
|
"resetCodeLabel": "Reset code",
|
||||||
|
"resetCodePlaceholder": "6-digit code",
|
||||||
|
"newPasswordLabel": "New password",
|
||||||
|
"setNewPassword": "Set new password",
|
||||||
|
"backToSignIn": "Back to sign in",
|
||||||
|
"welcomeTitle": "Welcome to FlatRender",
|
||||||
|
"signInSubtitle": "Sign in to continue to your dashboard",
|
||||||
|
"signUpSubtitle": "Create a free account to get started",
|
||||||
|
"signInTab": "Sign In",
|
||||||
|
"signUpTab": "Sign Up",
|
||||||
|
"emailLabel": "Email",
|
||||||
|
"passwordLabel": "Password",
|
||||||
|
"forgotPassword": "Forgot password?",
|
||||||
|
"createAccount": "Create Account",
|
||||||
|
"legalNotice": "By continuing, you agree to our <terms>Terms</terms> and <privacy>Privacy Policy</privacy>."
|
||||||
|
},
|
||||||
|
"componentsAuthSupabaseSetupNotice": {
|
||||||
|
"title": "Supabase not configured",
|
||||||
|
"instructions": "Copy <envExample></envExample> to <envLocal></envLocal> and set <supabaseUrl></supabaseUrl> and <supabaseAnonKey></supabaseAnonKey>, then restart the dev server.",
|
||||||
|
"continueDev": "Continue without signing in (dev only)",
|
||||||
|
"backToHome": "Back to home"
|
||||||
|
},
|
||||||
|
"componentsDashboardDashboardEmptyState": {
|
||||||
|
"title": "No projects yet",
|
||||||
|
"description": "Create a video, image, or trim project to see it here. Everything you save appears in this workspace.",
|
||||||
|
"createFirstProject": "Create your first project"
|
||||||
|
},
|
||||||
|
"componentsDashboardDashboardPlanBadge": {
|
||||||
|
"upgradePlan": "Upgrade plan"
|
||||||
|
},
|
||||||
|
"componentsDashboardDashboardProjectsSection": {
|
||||||
|
"recentProjects": "Recent Projects",
|
||||||
|
"noResultsTitle": "No projects match your search",
|
||||||
|
"noResultsDescription": "Try a different keyword or clear the search bar."
|
||||||
|
},
|
||||||
|
"componentsDashboardSettingsSettingsBilling": {
|
||||||
|
"title": "Billing & Plan",
|
||||||
|
"subtitle": "Manage your subscription and payment method.",
|
||||||
|
"currentPlan": "Current plan",
|
||||||
|
"planFree": "Free",
|
||||||
|
"planPro": "Pro",
|
||||||
|
"planBusiness": "Business",
|
||||||
|
"statusCancelsAtPeriodEnd": "Cancels at period end",
|
||||||
|
"statusActive": "Active",
|
||||||
|
"statusFreeTier": "Free tier",
|
||||||
|
"upgrade": "Upgrade",
|
||||||
|
"changePlan": "Change plan",
|
||||||
|
"cancelPlan": "Cancel plan",
|
||||||
|
"cancelling": "Cancelling…",
|
||||||
|
"cancelConfirm": "Cancel your plan? You'll keep access until the current period ends.",
|
||||||
|
"cancelFailed": "Failed to cancel plan. Please try again.",
|
||||||
|
"networkError": "Network error. Please try again.",
|
||||||
|
"cancelledNotice": "Your plan has been cancelled. You'll keep access until the end of your billing period.",
|
||||||
|
"upgradeHint": "Upgrade to unlock unlimited projects, 4K export, and premium templates.",
|
||||||
|
"featureFree5Projects": "5 projects",
|
||||||
|
"featureFree720pExport": "720p export",
|
||||||
|
"featureFreeCommunityTemplates": "Community templates",
|
||||||
|
"featureProUnlimitedProjects": "Unlimited projects",
|
||||||
|
"featurePro4kExport": "4K export",
|
||||||
|
"featureProAllTemplates": "All templates",
|
||||||
|
"featureProPriorityRenderQueue": "Priority render queue",
|
||||||
|
"featureProCustomFonts": "Custom fonts",
|
||||||
|
"featureBusinessEverythingInPro": "Everything in Pro",
|
||||||
|
"featureBusinessTeamSeats": "Team seats",
|
||||||
|
"featureBusinessWhiteLabelExport": "White-label export",
|
||||||
|
"featureBusinessApiAccess": "API access",
|
||||||
|
"featureBusinessDedicatedSupport": "Dedicated support"
|
||||||
|
},
|
||||||
|
"componentsDashboardSettingsSettingsNotifications": {
|
||||||
|
"title": "Notifications",
|
||||||
|
"subtitle": "Choose which emails you receive from FlatRender.",
|
||||||
|
"savePreferences": "Save preferences",
|
||||||
|
"saved": "Saved!",
|
||||||
|
"renderCompleteLabel": "Render complete",
|
||||||
|
"renderCompleteDescription": "Get notified when your video export finishes.",
|
||||||
|
"projectSharedLabel": "Project shared with you",
|
||||||
|
"projectSharedDescription": "When a team member shares a project.",
|
||||||
|
"weeklyDigestLabel": "Weekly digest",
|
||||||
|
"weeklyDigestDescription": "Summary of new templates and platform updates.",
|
||||||
|
"productNewsLabel": "Product news",
|
||||||
|
"productNewsDescription": "New features, tips, and announcements."
|
||||||
|
},
|
||||||
|
"componentsDashboardSettingsSettingsProfile": {
|
||||||
|
"title": "Profile",
|
||||||
|
"subtitle": "Your public name and account email.",
|
||||||
|
"displayNameLabel": "Display name",
|
||||||
|
"displayNamePlaceholder": "Your name",
|
||||||
|
"emailLabel": "Email",
|
||||||
|
"emailHint": "Email cannot be changed here. Contact support.",
|
||||||
|
"saving": "Saving…",
|
||||||
|
"saveChanges": "Save changes",
|
||||||
|
"updateFailed": "Could not update profile.",
|
||||||
|
"updateSuccess": "Profile updated successfully.",
|
||||||
|
"networkError": "Network error. Please try again."
|
||||||
|
},
|
||||||
|
"componentsDashboardSettingsSettingsSecurity": {
|
||||||
|
"title": "Security",
|
||||||
|
"subtitle": "Change your account password.",
|
||||||
|
"currentPasswordLabel": "Current password",
|
||||||
|
"newPasswordLabel": "New password",
|
||||||
|
"confirmPasswordLabel": "Confirm new password",
|
||||||
|
"showPassword": "Show password",
|
||||||
|
"hidePassword": "Hide password",
|
||||||
|
"saving": "Saving…",
|
||||||
|
"changePassword": "Change password",
|
||||||
|
"errorMinLength": "New password must be at least 8 characters.",
|
||||||
|
"errorMismatch": "Passwords do not match.",
|
||||||
|
"errorChangeFailed": "Could not change password.",
|
||||||
|
"changeSuccess": "Password changed successfully.",
|
||||||
|
"networkError": "Network error. Please try again."
|
||||||
|
},
|
||||||
|
"componentsImageMakerImageMakerBeforeAfter": {
|
||||||
|
"beforeAlt": "Before editing",
|
||||||
|
"afterAlt": "After editing with AI",
|
||||||
|
"beforeLabel": "Before",
|
||||||
|
"afterLabel": "After",
|
||||||
|
"caption": "AI-enhanced color, layout, and brand styling applied in one click"
|
||||||
|
},
|
||||||
|
"componentsImageMakerImageMakerGallery": {
|
||||||
|
"title": "Example outputs from creators",
|
||||||
|
"subtitle": "Real-world layouts and styles you can recreate—or use as inspiration for your next project."
|
||||||
|
},
|
||||||
|
"componentsLayoutNavbarMenuDropdown": {
|
||||||
|
"learn": "Learn"
|
||||||
|
},
|
||||||
|
"componentsLayoutNavbarMobileMenu": {
|
||||||
|
"videoMaker": "Video Maker",
|
||||||
|
"imageMaker": "Image Maker",
|
||||||
|
"pricing": "Pricing",
|
||||||
|
"learn": "Learn"
|
||||||
|
},
|
||||||
|
"componentsSectionsHeroPreviewCards": {
|
||||||
|
"heading": "Made by world-class motion designers",
|
||||||
|
"previewAriaLabel": "{label} preview",
|
||||||
|
"template3dTitle": "Factory of 3D Animations",
|
||||||
|
"templateWhiteboardTitle": "Whiteboard Animation Toolkit",
|
||||||
|
"templateExplainerTitle": "3D Explainer Video Toolkit",
|
||||||
|
"templateTrendyTitle": "Trendy Explainer Toolkit"
|
||||||
|
},
|
||||||
|
"componentsSectionsPricingAnimatedPrice": {
|
||||||
|
"perMonth": "/ month"
|
||||||
|
},
|
||||||
|
"componentsSectionsPricingBillingToggle": {
|
||||||
|
"monthly": "Monthly",
|
||||||
|
"yearly": "Yearly",
|
||||||
|
"savePercent": "Save {percent}%",
|
||||||
|
"switchToYearly": "Switch to Yearly to save more"
|
||||||
|
},
|
||||||
|
"componentsSectionsPricingCard": {
|
||||||
|
"mostPopular": "Most Popular"
|
||||||
|
},
|
||||||
|
"componentsTemplatesTemplateDetailExamples": {
|
||||||
|
"heading": "Videos created using this template"
|
||||||
|
},
|
||||||
|
"componentsTemplatesTemplateDetailInfo": {
|
||||||
|
"sceneCount": "{count} scenes",
|
||||||
|
"durationFlexible": "Flexible",
|
||||||
|
"durationFixed": "Fixed",
|
||||||
|
"fallbackDescription": "Create stunning videos with this professional template. Choose scenes, customize text, and export in minutes.",
|
||||||
|
"availableStyles": "Available styles ({count})",
|
||||||
|
"styleClassic": "Classic",
|
||||||
|
"styleModern": "Modern",
|
||||||
|
"styleBold": "Bold",
|
||||||
|
"styleMinimal": "Minimal",
|
||||||
|
"createNow": "Create Now",
|
||||||
|
"removeFromFavorites": "Remove from favorites",
|
||||||
|
"addToFavorites": "Add to favorites",
|
||||||
|
"createError": "Could not create project: {error}"
|
||||||
|
},
|
||||||
|
"componentsTemplatesTemplateDetailPreview": {
|
||||||
|
"posterAlt": "{name} preview",
|
||||||
|
"playPreview": "Play template preview"
|
||||||
|
},
|
||||||
|
"componentsTemplatesTemplateDetailRating": {
|
||||||
|
"starsAriaLabel": "{score} out of 5 stars",
|
||||||
|
"ratingsCount": "({count} Ratings)"
|
||||||
|
},
|
||||||
|
"componentsTemplatesTemplatesActiveFilters": {
|
||||||
|
"removeFilter": "Remove filter: {label}",
|
||||||
|
"searchLabel": "Search: \"{query}\""
|
||||||
|
},
|
||||||
|
"componentsTemplatesVideoVideoTemplatesHero": {
|
||||||
|
"breadcrumbHome": "Home",
|
||||||
|
"breadcrumbTemplates": "Templates",
|
||||||
|
"title": "Video Templates for All Your Needs",
|
||||||
|
"subtitle": "Find customizable video templates. Create animated promos, logo reveals, slideshows, and more with FlatRender's online video maker."
|
||||||
|
},
|
||||||
|
"componentsTemplatesVideoVideoTemplatesPageContent": {
|
||||||
|
"openTemplateError": "Could not open template: {error}",
|
||||||
|
"emptyStateTitle": "No templates match your filters",
|
||||||
|
"emptyStateDescription": "Try a different size, category, or search term."
|
||||||
|
},
|
||||||
|
"componentsTemplatesVideoVideoTemplatesToolbar": {
|
||||||
|
"searchPlaceholder": "Search thousands of templates",
|
||||||
|
"sortByLabel": "Sort by:",
|
||||||
|
"sortAriaLabel": "Sort templates",
|
||||||
|
"sortTrending": "Trending",
|
||||||
|
"sortNewest": "Newest",
|
||||||
|
"sortPopular": "Most Popular"
|
||||||
|
},
|
||||||
|
"componentsTrimmerTrimmerExportSection": {
|
||||||
|
"heading": "Export",
|
||||||
|
"processing": "Processing…",
|
||||||
|
"trimAndCrop": "Trim & Crop",
|
||||||
|
"loadingEngine": "Loading FFmpeg engine…",
|
||||||
|
"progress": "Progress",
|
||||||
|
"download": "Download {format}"
|
||||||
|
},
|
||||||
|
"componentsTrimmerTrimmerStrip": {
|
||||||
|
"heading": "Trim",
|
||||||
|
"trimStart": "Trim start",
|
||||||
|
"trimEnd": "Trim end"
|
||||||
|
},
|
||||||
|
"componentsTrimmerTrimmerUploadZone": {
|
||||||
|
"dropPrompt": "Drag & drop a video, or click to browse",
|
||||||
|
"supportedFormats": "MP4, WebM, MOV and other video formats"
|
||||||
|
},
|
||||||
|
"componentsDashboardDashboardSidebar": {
|
||||||
|
"currentPlan": "Current plan",
|
||||||
|
"signOut": "Sign out"
|
||||||
|
},
|
||||||
|
"componentsDashboardDashboardSidebarNav": {
|
||||||
|
"myProjects": "My Projects",
|
||||||
|
"templates": "Templates",
|
||||||
|
"upgrade": "Upgrade",
|
||||||
|
"settings": "Settings",
|
||||||
|
"navLabel": "Dashboard"
|
||||||
|
},
|
||||||
|
"componentsDashboardDashboardTopBar": {
|
||||||
|
"searchPlaceholder": "Search projects..."
|
||||||
|
},
|
||||||
|
"componentsSectionsPricingCompareTable": {
|
||||||
|
"mostPopular": "Most Popular",
|
||||||
|
"compareHeading": "Compare Plans & Features",
|
||||||
|
"saveUpTo": "Save up to {percent}%"
|
||||||
|
},
|
||||||
|
"componentsSectionsPricingCreditsBanner": {
|
||||||
|
"refillCredits": "You can refill AI credits anytime with an active plan"
|
||||||
|
},
|
||||||
|
"componentsSectionsPricingFeatureList": {
|
||||||
|
"moreInformation": "More information"
|
||||||
|
},
|
||||||
|
"componentsSectionsPricingFreeBanner": {
|
||||||
|
"title": "Always Free to Try",
|
||||||
|
"description": "Explore CreatorStudio with a Free plan — create HD videos with a watermark, try basic features, and experiment before you subscribe.",
|
||||||
|
"ctaLabel": "Get Started"
|
||||||
|
},
|
||||||
|
"componentsSectionsTemplateCard": {
|
||||||
|
"useTemplateLabel": "Use Template",
|
||||||
|
"openingLabel": "Opening…",
|
||||||
|
"viewTemplateAriaLabel": "View {name} template"
|
||||||
|
},
|
||||||
|
"componentsSectionsTestimonialCard": {
|
||||||
|
"ratingLabel": "Rated 5 out of 5 stars"
|
||||||
|
},
|
||||||
|
"componentsTemplatesTemplateDetailBreadcrumb": {
|
||||||
|
"breadcrumbAriaLabel": "Breadcrumb",
|
||||||
|
"home": "Home",
|
||||||
|
"templates": "Templates"
|
||||||
|
},
|
||||||
|
"appImageMakerPage": {
|
||||||
|
"metaTitle": "AI Image Maker",
|
||||||
|
"metaDescription": "Design professional visuals instantly with AI generation, templates, brand kits, and batch export."
|
||||||
|
},
|
||||||
|
"appPage": {
|
||||||
|
"metaTitle": "Create Pro Videos & Images with AI",
|
||||||
|
"metaDescription": "FlatRender helps creators and brands make professional videos and images with AI templates, editors, and one-click export."
|
||||||
|
},
|
||||||
|
"componentsDashboardNewProjectMenu": {
|
||||||
|
"newProject": "New Project",
|
||||||
|
"creating": "Creating…",
|
||||||
|
"videoProject": "Video Project",
|
||||||
|
"imageProject": "Image Project",
|
||||||
|
"trimCropVideo": "Trim/Crop Video"
|
||||||
|
},
|
||||||
|
"componentsDashboardProjectCard": {
|
||||||
|
"openInStudio": "Open in Studio",
|
||||||
|
"download": "Download",
|
||||||
|
"rename": "Rename",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
|
"delete": "Delete",
|
||||||
|
"statusRendering": "Rendering",
|
||||||
|
"statusReady": "Ready",
|
||||||
|
"statusDraft": "Draft",
|
||||||
|
"actionsFor": "Actions for {name}"
|
||||||
|
},
|
||||||
|
"componentsSectionsPricingCheckoutButton": {
|
||||||
|
"checkoutFailed": "Checkout failed.",
|
||||||
|
"noCheckoutUrl": "No checkout URL returned."
|
||||||
|
},
|
||||||
|
"componentsTemplatesTemplatesSidebar": {
|
||||||
|
"categoryHeading": "Category",
|
||||||
|
"styleHeading": "Style",
|
||||||
|
"colorHeading": "Color"
|
||||||
|
},
|
||||||
|
"componentsTemplatesVideoVideoTemplateCompactCard": {
|
||||||
|
"viewTemplateAria": "View {name} template",
|
||||||
|
"opening": "Opening…",
|
||||||
|
"useTemplate": "Use Template",
|
||||||
|
"sceneCount": "{count} scenes"
|
||||||
|
},
|
||||||
|
"componentsTemplatesVideoVideoTemplatesCarouselRow": {
|
||||||
|
"seeAll": "See all",
|
||||||
|
"scrollLeftAria": "Scroll {title} left",
|
||||||
|
"scrollRightAria": "Scroll {title} right"
|
||||||
|
},
|
||||||
|
"componentsTemplatesVideoVideoTemplatesCategorySidebar": {
|
||||||
|
"categoriesNavLabel": "Template categories",
|
||||||
|
"categoryAll": "All Templates",
|
||||||
|
"categoryAnimation": "Animation Videos",
|
||||||
|
"categoryIntros": "Intros and Logos",
|
||||||
|
"categoryEditing": "Video Editing",
|
||||||
|
"categoryInvitation": "Invitation Videos",
|
||||||
|
"categoryHoliday": "Holiday Videos",
|
||||||
|
"categorySlideshow": "Slideshow",
|
||||||
|
"categoryPresentations": "Presentations",
|
||||||
|
"categorySocial": "Social Media Videos",
|
||||||
|
"categoryAds": "Video Ad Templates",
|
||||||
|
"categorySales": "Sales Videos",
|
||||||
|
"categoryMusic": "Music Visualization",
|
||||||
|
"filters": "Filters",
|
||||||
|
"sizeLabel": "Size"
|
||||||
|
},
|
||||||
|
"componentsTemplatesVideoVideoTemplatesFilterControls": {
|
||||||
|
"premiumOnly": "Premium Only",
|
||||||
|
"premiumOnlyAriaLabel": "Premium only",
|
||||||
|
"sizeAriaLabel": "Template size",
|
||||||
|
"sizePlaceholder": "All Sizes"
|
||||||
|
},
|
||||||
|
"componentsTrimmerTrimmerVideoPreview": {
|
||||||
|
"previewAndCrop": "Preview & crop",
|
||||||
|
"aspectFree": "Free",
|
||||||
|
"aspect16x9": "16:9",
|
||||||
|
"aspect9x16": "9:16",
|
||||||
|
"aspect1x1": "1:1",
|
||||||
|
"aspect4x3": "4:3"
|
||||||
|
},
|
||||||
|
"componentsVideoMakerVideoMakerEditorPreview": {
|
||||||
|
"appBarTitle": "CreatorStudio — Video Editor",
|
||||||
|
"sceneCaption": "Scene 2 · Product reveal · 00:12",
|
||||||
|
"layersHeading": "Layers",
|
||||||
|
"layerIntroTitle": "Intro title",
|
||||||
|
"layerBrollClip": "B-roll clip",
|
||||||
|
"layerBackgroundMusic": "Background music",
|
||||||
|
"layerCaptions": "Captions"
|
||||||
|
},
|
||||||
|
"componentsVideoMakerVideoMakerTemplateCarousel": {
|
||||||
|
"title": "Video templates for every story",
|
||||||
|
"subtitle": "Start from a proven layout and customize scenes, text, and music in minutes.",
|
||||||
|
"templatePromo": "Product Promo",
|
||||||
|
"templateYoutube": "YouTube Intro",
|
||||||
|
"templateReel": "Reel Hook",
|
||||||
|
"templateCorporate": "Corporate Update",
|
||||||
|
"templateAd": "Ad Spotlight",
|
||||||
|
"templateTutorial": "Tutorial",
|
||||||
|
"templateEvent": "Event Recap",
|
||||||
|
"templateTestimonial": "Customer Story"
|
||||||
|
},
|
||||||
|
"componentsImageEditorAiRemoveBgModal": {
|
||||||
|
"openImageFirst": "Open an image first.",
|
||||||
|
"removalFailed": "Background removal failed.",
|
||||||
|
"backgroundRemoved": "Background removed!",
|
||||||
|
"serviceUnreachable": "Could not reach background removal service.",
|
||||||
|
"title": "AI Background Removal",
|
||||||
|
"description": "Remove the background from your base image. The result replaces the background layer with a transparent PNG.",
|
||||||
|
"processing": "Processing…",
|
||||||
|
"removeBackground": "Remove Background"
|
||||||
|
},
|
||||||
|
"componentsImageEditorImageCropControls": {
|
||||||
|
"aspectFree": "Free",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"applying": "Applying…",
|
||||||
|
"applyCrop": "Apply Crop"
|
||||||
|
},
|
||||||
|
"componentsImageEditorImageEditorRightPanel": {
|
||||||
|
"tabAdjust": "Adjust",
|
||||||
|
"tabFilters": "Filters",
|
||||||
|
"tabLayers": "Layers"
|
||||||
|
},
|
||||||
|
"componentsImageEditorImageEditorToolbar": {
|
||||||
|
"toolSelect": "Select",
|
||||||
|
"toolCrop": "Crop",
|
||||||
|
"toolText": "Text",
|
||||||
|
"toolShape": "Shape",
|
||||||
|
"toolDraw": "Draw",
|
||||||
|
"toolAi": "AI",
|
||||||
|
"shapeRectangle": "Rectangle",
|
||||||
|
"shapeCircle": "Circle",
|
||||||
|
"shapeLine": "Line",
|
||||||
|
"shapeArrow": "Arrow"
|
||||||
|
},
|
||||||
|
"componentsImageEditorImageEditorTopBar": {
|
||||||
|
"defaultProjectName": "Image Editor",
|
||||||
|
"open": "Open",
|
||||||
|
"export": "Export",
|
||||||
|
"format": "Format",
|
||||||
|
"quality": "Quality",
|
||||||
|
"download": "Download",
|
||||||
|
"canvasNotReady": "Canvas not ready.",
|
||||||
|
"exportStarted": "Export started"
|
||||||
|
},
|
||||||
|
"componentsImageEditorPanelsAdjustPanel": {
|
||||||
|
"emptyState": "Open an image to use adjustments.",
|
||||||
|
"brightness": "Brightness",
|
||||||
|
"contrast": "Contrast",
|
||||||
|
"saturation": "Saturation",
|
||||||
|
"hue": "Hue",
|
||||||
|
"blur": "Blur",
|
||||||
|
"sharpen": "Sharpen",
|
||||||
|
"vignette": "Vignette"
|
||||||
|
},
|
||||||
|
"componentsImageEditorPanelsFiltersPanel": {
|
||||||
|
"emptyState": "Open an image to apply filters."
|
||||||
|
},
|
||||||
|
"componentsImageEditorPanelsLayersPanel": {
|
||||||
|
"reorderLayer": "Reorder {name}",
|
||||||
|
"hideLayer": "Hide layer",
|
||||||
|
"showLayer": "Show layer",
|
||||||
|
"deleteLayer": "Delete {name}",
|
||||||
|
"emptyState": "No layers yet."
|
||||||
|
},
|
||||||
|
"componentsStudioAddSceneMenu": {
|
||||||
|
"addScene": "Add Scene",
|
||||||
|
"blankScene": "Blank Scene",
|
||||||
|
"fromTemplate": "From Template"
|
||||||
|
},
|
||||||
|
"componentsStudioDraggableSceneItem": {
|
||||||
|
"dragScene": "Drag scene {name}",
|
||||||
|
"sceneNameLabel": "Scene name"
|
||||||
|
},
|
||||||
|
"componentsStudioProjectSaveIndicator": {
|
||||||
|
"saving": "Saving…",
|
||||||
|
"saved": "Saved",
|
||||||
|
"localSave": "Local save",
|
||||||
|
"saveFailed": "Save failed",
|
||||||
|
"retry": "Retry"
|
||||||
|
},
|
||||||
|
"componentsStudioPropertiesPanel": {
|
||||||
|
"title": "Properties",
|
||||||
|
"emptyState": "Select a layer to edit properties",
|
||||||
|
"layerLabel": "{type} layer"
|
||||||
|
},
|
||||||
|
"componentsStudioRenderModal": {
|
||||||
|
"dialogTitle": "Export",
|
||||||
|
"dialogDescription": "Export your project as MP4 via the nexrender pipeline.",
|
||||||
|
"videoReady": "Your video is ready.",
|
||||||
|
"downloadMp4": "Download MP4",
|
||||||
|
"shareLink": "Share link",
|
||||||
|
"close": "Close",
|
||||||
|
"errorGeneric": "Something went wrong.",
|
||||||
|
"retry": "Retry",
|
||||||
|
"previewAlt": "Render preview",
|
||||||
|
"rendering": "Rendering…",
|
||||||
|
"progress": "Progress",
|
||||||
|
"resolution": "Resolution",
|
||||||
|
"format": "Format",
|
||||||
|
"fps": "FPS",
|
||||||
|
"startRendering": "Start Rendering",
|
||||||
|
"errorFetchStatus": "Could not fetch render status.",
|
||||||
|
"renderingProgress": "Rendering… {progress}%",
|
||||||
|
"errorRenderFailed": "Render failed.",
|
||||||
|
"errorNetworkPolling": "Network error while polling status.",
|
||||||
|
"errorStartRender": "Failed to start render.",
|
||||||
|
"queued": "Queued for rendering…",
|
||||||
|
"errorReachApi": "Could not reach render API."
|
||||||
|
},
|
||||||
|
"componentsStudioSceneBrowserCard": {
|
||||||
|
"selectCta": "Select"
|
||||||
|
},
|
||||||
|
"componentsStudioSceneBrowserModal": {
|
||||||
|
"title": "Select Scenes",
|
||||||
|
"closeAriaLabel": "Close",
|
||||||
|
"filterAll": "All",
|
||||||
|
"filterVideo": "Video",
|
||||||
|
"filterPhoto": "Photo",
|
||||||
|
"searchPlaceholder": "Search scenes...",
|
||||||
|
"emptyState": "No scenes match your filters.",
|
||||||
|
"selectedSuffix": "{count, plural, one {scene selected} other {scenes selected}}",
|
||||||
|
"deselectAll": "Deselect All",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"addToVideo": "Add to Video",
|
||||||
|
"addToVideoCount": "Add to Video ({count})"
|
||||||
|
},
|
||||||
|
"componentsStudioSceneItemActions": {
|
||||||
|
"duplicate": "Duplicate {sceneName}",
|
||||||
|
"delete": "Delete {sceneName}"
|
||||||
|
},
|
||||||
|
"componentsStudioSceneTransitionPicker": {
|
||||||
|
"transition": "Transition"
|
||||||
|
},
|
||||||
|
"componentsStudioStudioMobileGate": {
|
||||||
|
"titleVideo": "The Video Studio requires a desktop browser.",
|
||||||
|
"titleImage": "The Image Editor requires a desktop browser.",
|
||||||
|
"description": "Please open this project on a desktop or laptop.",
|
||||||
|
"dashboardCta": "Go to Dashboard"
|
||||||
|
},
|
||||||
|
"componentsStudioStudioToolbar": {
|
||||||
|
"defaultText": "Edit this text",
|
||||||
|
"addText": "Add text",
|
||||||
|
"addImage": "Add image",
|
||||||
|
"addVideoClip": "Add video clip",
|
||||||
|
"addShape": "Add shape",
|
||||||
|
"shapeRectangle": "Rectangle",
|
||||||
|
"shapeCircle": "Circle",
|
||||||
|
"shapeLine": "Line",
|
||||||
|
"shapeArrow": "Arrow"
|
||||||
|
},
|
||||||
|
"componentsStudioCanvasVideoLayerNode": {
|
||||||
|
"defaultFileName": "Video",
|
||||||
|
"placeholder": "Video clip"
|
||||||
|
},
|
||||||
|
"componentsStudioPropertiesCommonLayerControls": {
|
||||||
|
"transformTitle": "Transform",
|
||||||
|
"widthLabel": "Width",
|
||||||
|
"heightLabel": "Height",
|
||||||
|
"rotationLabel": "Rotation (°)",
|
||||||
|
"layerOrderTitle": "Layer order",
|
||||||
|
"toFront": "To front",
|
||||||
|
"toBack": "To back",
|
||||||
|
"deleteLayer": "Delete layer"
|
||||||
|
},
|
||||||
|
"componentsStudioPropertiesImageLayerProperties": {
|
||||||
|
"sectionTitle": "Image",
|
||||||
|
"opacity": "Opacity",
|
||||||
|
"flipHorizontal": "Flip H",
|
||||||
|
"flipVertical": "Flip V",
|
||||||
|
"replaceImage": "Replace image",
|
||||||
|
"borderRadius": "Border radius"
|
||||||
|
},
|
||||||
|
"componentsStudioPropertiesPropertyControls": {
|
||||||
|
"lockAspectRatio": "Lock aspect ratio",
|
||||||
|
"unlockAspectRatio": "Unlock aspect ratio"
|
||||||
|
},
|
||||||
|
"componentsStudioPropertiesShapeLayerProperties": {
|
||||||
|
"sectionTitle": "Shape",
|
||||||
|
"fillColor": "Fill color",
|
||||||
|
"strokeColor": "Stroke color",
|
||||||
|
"strokeWidth": "Stroke width",
|
||||||
|
"borderRadius": "Border radius",
|
||||||
|
"opacity": "Opacity"
|
||||||
|
},
|
||||||
|
"componentsStudioPropertiesTextLayerProperties": {
|
||||||
|
"sectionTitle": "Text",
|
||||||
|
"fontFamily": "Font family",
|
||||||
|
"fontSize": "Font size",
|
||||||
|
"bold": "Bold",
|
||||||
|
"italic": "Italic",
|
||||||
|
"underline": "Underline",
|
||||||
|
"textColor": "Text color",
|
||||||
|
"alignment": "Alignment",
|
||||||
|
"alignLeft": "Left",
|
||||||
|
"alignCenter": "Center",
|
||||||
|
"alignRight": "Right",
|
||||||
|
"letterSpacing": "Letter spacing",
|
||||||
|
"lineHeight": "Line height",
|
||||||
|
"opacity": "Opacity",
|
||||||
|
"animation": "Animation"
|
||||||
|
},
|
||||||
|
"componentsStudioSidebarAudioSidebarContent": {
|
||||||
|
"musicTab": "Music",
|
||||||
|
"voiceoverTab": "Voiceover"
|
||||||
|
},
|
||||||
|
"componentsStudioSidebarAudioSidebarMusicTab": {
|
||||||
|
"upload": "Upload",
|
||||||
|
"includeTemplateSfx": "Include template sound effect",
|
||||||
|
"searchPlaceholder": "Search music",
|
||||||
|
"musicLibrary": "Music library",
|
||||||
|
"myMusic": "My music",
|
||||||
|
"uploadOwnMusic": "Upload your own music"
|
||||||
|
},
|
||||||
|
"componentsStudioSidebarAudioSidebarVoiceoverPane": {
|
||||||
|
"comingSoon": "Coming soon",
|
||||||
|
"description": "Generate voiceovers from your script directly in the studio."
|
||||||
|
},
|
||||||
|
"componentsStudioSidebarColorsCustomTab": {
|
||||||
|
"mainColor": "Main Color",
|
||||||
|
"additionalColor": "Additional Color",
|
||||||
|
"applyToAllScenes": "Apply to all scenes"
|
||||||
|
},
|
||||||
|
"componentsStudioSidebarColorsPalettesTab": {
|
||||||
|
"paletteFallback": "Palette {number}",
|
||||||
|
"applyPaletteAriaLabel": "Apply {name} palette"
|
||||||
|
},
|
||||||
|
"componentsStudioSidebarColorsSidebarContent": {
|
||||||
|
"palettesTab": "Palettes",
|
||||||
|
"customTab": "Custom"
|
||||||
|
},
|
||||||
|
"componentsStudioSidebarColorsTemplatePreviewCard": {
|
||||||
|
"mainColor": "Main Color",
|
||||||
|
"additional": "Additional",
|
||||||
|
"paletteFallback": "Palette {number}"
|
||||||
|
},
|
||||||
|
"componentsStudioSidebarFontSidebarContent": {
|
||||||
|
"title": "Font",
|
||||||
|
"fontFamily": "Font family",
|
||||||
|
"applyToAll": "Apply to all text layers"
|
||||||
|
},
|
||||||
|
"componentsStudioSidebarSceneEditSidebarContent": {
|
||||||
|
"panelTitle": "Edit Scene",
|
||||||
|
"titleLabel": "Title",
|
||||||
|
"subtitleLabel": "Subtitle",
|
||||||
|
"textLabel": "Text {index}",
|
||||||
|
"textPlaceholder": "Type here…",
|
||||||
|
"imageLabel": "Image {index}",
|
||||||
|
"emptyStateTitle": "This scene has no content yet.",
|
||||||
|
"emptyStateHint": "Add a text layer to start editing.",
|
||||||
|
"addTextLayer": "Add Text Layer",
|
||||||
|
"defaultText": "Your text here",
|
||||||
|
"replaceImage": "Replace image",
|
||||||
|
"uploadImage": "Upload image"
|
||||||
|
},
|
||||||
|
"componentsStudioSidebarTransitionsSidebarContent": {
|
||||||
|
"heading": "Transitions",
|
||||||
|
"randomTransition": "Random Transition",
|
||||||
|
"noTransition": "No Transition",
|
||||||
|
"exportNote": "Applied transitions will be visible on all scenes after export."
|
||||||
|
},
|
||||||
|
"componentsStudioSidebarTtsSidebarContent": {
|
||||||
|
"title": "Text to Speech",
|
||||||
|
"comingSoon": "Coming soon",
|
||||||
|
"description": "Generate voiceovers from your script directly in the studio."
|
||||||
|
},
|
||||||
|
"componentsStudioSidebarWatermarkSidebarContent": {
|
||||||
|
"title": "My Watermark",
|
||||||
|
"applyToAllScenes": "Apply to all scenes",
|
||||||
|
"uploadLogo": "Upload your watermark logo",
|
||||||
|
"uploadHint": "PNG or SVG, max 2MB",
|
||||||
|
"position": "Position",
|
||||||
|
"positionTopLeft": "Top left",
|
||||||
|
"positionTopCenter": "Top center",
|
||||||
|
"positionTopRight": "Top right",
|
||||||
|
"positionMiddleLeft": "Middle left",
|
||||||
|
"positionCenter": "Center",
|
||||||
|
"positionMiddleRight": "Middle right",
|
||||||
|
"positionBottomLeft": "Bottom left",
|
||||||
|
"positionBottomCenter": "Bottom center",
|
||||||
|
"positionBottomRight": "Bottom right",
|
||||||
|
"opacity": "Opacity",
|
||||||
|
"opacityAriaLabel": "Watermark opacity"
|
||||||
|
},
|
||||||
|
"componentsStudioTimelineAudioTrack": {
|
||||||
|
"emptyState": "No audio — click to add"
|
||||||
|
},
|
||||||
|
"componentsStudioTimelineSceneBlock": {
|
||||||
|
"resizeDuration": "Resize {name} duration"
|
||||||
|
},
|
||||||
|
"componentsStudioTimelineSceneThumbnailBlock": {
|
||||||
|
"duplicateScene": "Duplicate {name}",
|
||||||
|
"deleteScene": "Delete {name}",
|
||||||
|
"resizeSceneDuration": "Resize {name} duration",
|
||||||
|
"sceneNameLabel": "Scene name",
|
||||||
|
"doubleClickToRename": "Double-click to rename"
|
||||||
|
},
|
||||||
|
"componentsStudioTimelineSceneThumbnailStrip": {
|
||||||
|
"browseScenes": "Browse scenes",
|
||||||
|
"addScene": "Add scene"
|
||||||
|
},
|
||||||
|
"componentsStudioTimelineTimeRuler": {
|
||||||
|
"rulerAriaLabel": "Timeline ruler — click to seek"
|
||||||
|
},
|
||||||
|
"componentsStudioTimelineTimelineActionRow": {
|
||||||
|
"addTextToSpeech": "Add text to speech",
|
||||||
|
"addAudio": "Add audio"
|
||||||
|
},
|
||||||
|
"componentsStudioTimelineTimelineControlBar": {
|
||||||
|
"copyLayer": "Copy layer",
|
||||||
|
"deleteLayer": "Delete layer",
|
||||||
|
"stop": "Stop",
|
||||||
|
"preview": "Preview",
|
||||||
|
"previewFromStart": "Preview from start",
|
||||||
|
"seekToStart": "Seek to start",
|
||||||
|
"zoomOut": "Zoom out",
|
||||||
|
"zoomIn": "Zoom in",
|
||||||
|
"timelineZoom": "Timeline zoom"
|
||||||
|
},
|
||||||
|
"componentsStudioTimelineTimelineQuickActions": {
|
||||||
|
"addTextToSpeech": "Add text to speech",
|
||||||
|
"addAudio": "Add audio"
|
||||||
|
},
|
||||||
|
"componentsStudioVideoCanvasArea": {
|
||||||
|
"loading": "Loading canvas…",
|
||||||
|
"editingNotice": "You're in editing mode — visuals may look different. Press <preview>Preview</preview> to see the final result."
|
||||||
|
},
|
||||||
|
"componentsStudioVideoStudioSidebarDock": {
|
||||||
|
"scenes": "Scenes",
|
||||||
|
"audio": "Audio",
|
||||||
|
"textToSpeech": "Text to Speech",
|
||||||
|
"colors": "Colors",
|
||||||
|
"transitions": "Transitions",
|
||||||
|
"font": "Font",
|
||||||
|
"myWatermark": "My Watermark",
|
||||||
|
"toolsNavLabel": "Studio tools",
|
||||||
|
"guideMe": "Guide me",
|
||||||
|
"guideComingSoon": "👋 Guide coming soon!",
|
||||||
|
"keyboardShortcuts": "Keyboard shortcuts",
|
||||||
|
"keyboardShortcutsComingSoon": "Keyboard shortcuts coming soon!"
|
||||||
|
},
|
||||||
|
"componentsStudioVideoStudioTopBar": {
|
||||||
|
"snapshotSaved": "Snapshot saved!",
|
||||||
|
"canvasNotReady": "Canvas not ready. Try again.",
|
||||||
|
"homeLink": "FlatRender home",
|
||||||
|
"breadcrumb": "Breadcrumb",
|
||||||
|
"myProjects": "My Projects",
|
||||||
|
"projectName": "Project name",
|
||||||
|
"undo": "Undo",
|
||||||
|
"redo": "Redo",
|
||||||
|
"stop": "Stop",
|
||||||
|
"preview": "Preview",
|
||||||
|
"takeSnapshot": "Take snapshot",
|
||||||
|
"export": "Export"
|
||||||
|
},
|
||||||
|
"componentsStudioVideoStudioTopBarSaveBadge": {
|
||||||
|
"savingTitle": "Saving…",
|
||||||
|
"savingLabel": "Saving",
|
||||||
|
"errorTitle": "Save failed",
|
||||||
|
"errorLabel": "Save failed",
|
||||||
|
"local": "Local",
|
||||||
|
"saved": "Saved ✓"
|
||||||
|
},
|
||||||
|
"componentsStudioVideoStudioTopBarTextControls": {
|
||||||
|
"groupLabel": "Text layer properties",
|
||||||
|
"fontFamily": "Font family",
|
||||||
|
"fontSize": "Font size",
|
||||||
|
"bold": "Bold",
|
||||||
|
"italic": "Italic",
|
||||||
|
"textColor": "Text color"
|
||||||
|
},
|
||||||
|
"componentsStudioVideoVideoNewPresetCard": {
|
||||||
|
"useTemplate": "Use Template"
|
||||||
|
},
|
||||||
|
"componentsStudioVideoVideoProjectNewContent": {
|
||||||
|
"breadcrumbCreate": "Create new video",
|
||||||
|
"heading": "Select one of the options to start creating",
|
||||||
|
"selectScenesTitle": "Select Scenes",
|
||||||
|
"selectScenesDescription": "Browse scenes and build your project from scratch",
|
||||||
|
"createWithAiTitle": "Create with AI",
|
||||||
|
"createWithAiDescription": "Transform your ideas or script into AI-generated videos effortlessly",
|
||||||
|
"aiProjectName": "AI Video Project",
|
||||||
|
"or": "OR",
|
||||||
|
"startWithPresets": "Start with Presets",
|
||||||
|
"searchPresetsPlaceholder": "Search presets...",
|
||||||
|
"newVideoName": "New Video"
|
||||||
|
},
|
||||||
|
"adminAi": {
|
||||||
|
"pageTitle": "AI SEO Content",
|
||||||
|
"pageDesc": "Configure OpenAI and generate SEO-optimized articles from a description.",
|
||||||
|
"settingsTitle": "OpenAI configuration",
|
||||||
|
"settingsDesc": "Your API key is stored securely and never shown in full. Point Base URL at a reachable OpenAI-compatible endpoint if needed.",
|
||||||
|
"apiKeyLabel": "API key",
|
||||||
|
"apiKeyPlaceholder": "sk-… (leave blank to keep current)",
|
||||||
|
"baseUrlLabel": "Base URL",
|
||||||
|
"modelLabel": "Model",
|
||||||
|
"enabledLabel": "Enable AI generation",
|
||||||
|
"saveSettings": "Save settings",
|
||||||
|
"saving": "Saving…",
|
||||||
|
"settingsSaved": "Settings saved",
|
||||||
|
"settingsError": "Could not save settings",
|
||||||
|
"keyConfigured": "API key configured",
|
||||||
|
"noKey": "No API key set",
|
||||||
|
"generateTitle": "Generate SEO article",
|
||||||
|
"generateDesc": "Describe the topic and metadata — the AI writes an SEO-ready post.",
|
||||||
|
"descriptionLabel": "Description / brief",
|
||||||
|
"descriptionPlaceholder": "What is this page/product about? Key points, tone, goals…",
|
||||||
|
"titleLabel": "Working title (optional)",
|
||||||
|
"typeLabel": "Content type (optional)",
|
||||||
|
"typePlaceholder": "e.g. video template",
|
||||||
|
"tagsLabel": "Tags (comma separated, optional)",
|
||||||
|
"keywordLabel": "Primary keyword (optional)",
|
||||||
|
"audienceLabel": "Audience (optional)",
|
||||||
|
"localeLabel": "Language",
|
||||||
|
"localeFa": "Persian",
|
||||||
|
"localeEn": "English",
|
||||||
|
"generate": "Generate",
|
||||||
|
"generating": "Generating…",
|
||||||
|
"generateError": "Generation failed",
|
||||||
|
"resultTitle": "Generated article",
|
||||||
|
"fTitle": "Title",
|
||||||
|
"fSlug": "Slug",
|
||||||
|
"fMetaTitle": "Meta title",
|
||||||
|
"fMetaDesc": "Meta description",
|
||||||
|
"fKeywords": "Keywords",
|
||||||
|
"fShortDesc": "Short description",
|
||||||
|
"fContent": "Content (HTML)",
|
||||||
|
"preview": "Preview",
|
||||||
|
"publishNow": "Publish immediately",
|
||||||
|
"saveAsBlog": "Save as blog post",
|
||||||
|
"savedAsBlog": "Saved as blog post",
|
||||||
|
"saveError": "Could not save post",
|
||||||
|
"mustConfigure": "Configure and enable OpenAI above before generating."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -305,5 +305,905 @@
|
|||||||
"all": "همه",
|
"all": "همه",
|
||||||
"popular": "محبوب",
|
"popular": "محبوب",
|
||||||
"new": "جدید"
|
"new": "جدید"
|
||||||
|
},
|
||||||
|
"auto": {
|
||||||
|
"appAdminLayout": {
|
||||||
|
"brand": "پنل مدیریت FlatRender",
|
||||||
|
"nodes": "نودها",
|
||||||
|
"renderQueue": "صف رندر",
|
||||||
|
"backToDashboard": "← بازگشت به داشبورد",
|
||||||
|
"aiContent": "محتوای هوش مصنوعی",
|
||||||
|
"categories": "دستهبندیها",
|
||||||
|
"tags": "برچسبها",
|
||||||
|
"fonts": "فونتها",
|
||||||
|
"blogs": "بلاگ",
|
||||||
|
"slides": "اسلایدها",
|
||||||
|
"users": "کاربران",
|
||||||
|
"plans": "پلنها"
|
||||||
|
},
|
||||||
|
"appAdminNodesPage": {
|
||||||
|
"title": "نودهای رندر",
|
||||||
|
"registered": "{count, plural, other {# نود ثبتشده}}"
|
||||||
|
},
|
||||||
|
"appAdminRendersPage": {
|
||||||
|
"title": "صف رندر",
|
||||||
|
"totalJobs": "{total} کار در مجموع",
|
||||||
|
"filterAll": "همه",
|
||||||
|
"stepQueued": "در صف",
|
||||||
|
"stepPreparing": "در حال آمادهسازی",
|
||||||
|
"stepRendering": "در حال رندر",
|
||||||
|
"stepUploading": "در حال آپلود",
|
||||||
|
"stepDone": "انجامشده",
|
||||||
|
"stepFailed": "ناموفق",
|
||||||
|
"stepCancelled": "لغوشده"
|
||||||
|
},
|
||||||
|
"appAuthPage": {
|
||||||
|
"metaTitle": "ورود",
|
||||||
|
"metaDescription": "وارد حساب CreatorStudio خود شوید یا یک حساب جدید بسازید.",
|
||||||
|
"loading": "در حال بارگذاری..."
|
||||||
|
},
|
||||||
|
"appDashboardSettingsPage": {
|
||||||
|
"title": "تنظیمات",
|
||||||
|
"subtitle": "حساب کاربری، امنیت و تنظیمات اعلانهای خود را مدیریت کنید.",
|
||||||
|
"dangerZoneTitle": "منطقه خطر",
|
||||||
|
"dangerZoneDescription": "حساب کاربری و همه پروژههای شما برای همیشه حذف میشوند. این عمل قابل بازگشت نیست.",
|
||||||
|
"deleteAccount": "حذف حساب کاربری"
|
||||||
|
},
|
||||||
|
"appError": {
|
||||||
|
"title": "مشکلی پیش آمد",
|
||||||
|
"description": "خطایی غیرمنتظره رخ داد. لطفاً صفحه را دوباره بارگذاری کنید.",
|
||||||
|
"reloadButton": "بارگذاری مجدد صفحه"
|
||||||
|
},
|
||||||
|
"appNotFound": {
|
||||||
|
"title": "صفحه پیدا نشد",
|
||||||
|
"description": "صفحهای که به دنبال آن هستید وجود ندارد یا ممکن است جابهجا شده باشد.",
|
||||||
|
"goHome": "بازگشت به خانه"
|
||||||
|
},
|
||||||
|
"appStudioImageProjectIdPage": {
|
||||||
|
"loadingEditor": "در حال بارگذاری ویرایشگر…"
|
||||||
|
},
|
||||||
|
"appStudioTrimmerPage": {
|
||||||
|
"back": "بازگشت",
|
||||||
|
"title": "برش و قاببندی ویدیو",
|
||||||
|
"ffmpegLoadError": "بارگذاری FFmpeg ناموفق بود. اتصال خود را بررسی کنید و دوباره تلاش کنید.",
|
||||||
|
"processingError": "پردازش ناموفق بود. کلیپ کوتاهتر یا قالب دیگری را امتحان کنید."
|
||||||
|
},
|
||||||
|
"appStudioVideoProjectIdPage": {
|
||||||
|
"loading": "در حال بارگذاری استودیو…"
|
||||||
|
},
|
||||||
|
"appVideoMakerPage": {
|
||||||
|
"metaTitle": "ویدیوساز هوش مصنوعی",
|
||||||
|
"metaDescription": "در چند دقیقه ویدیوهای حرفهای بسازید؛ با فیلمنامه هوش مصنوعی، زیرنویس خودکار، بیش از ۵۰۰ قالب و خروجی تککلیکی."
|
||||||
|
},
|
||||||
|
"componentsAdminNodesTable": {
|
||||||
|
"emptyState": "هیچ نودی ثبت نشده است. برای نمایش، عامل نود را روی یک دستگاه رندر اجرا کنید.",
|
||||||
|
"colNode": "نود",
|
||||||
|
"colStatus": "وضعیت",
|
||||||
|
"colSlots": "اسلاتها",
|
||||||
|
"colHeartbeat": "ضربان",
|
||||||
|
"colActiveJob": "کار فعال",
|
||||||
|
"colTags": "برچسبها",
|
||||||
|
"colActions": "عملیات",
|
||||||
|
"actionDrain": "تخلیه",
|
||||||
|
"actionRelease": "آزادسازی"
|
||||||
|
},
|
||||||
|
"componentsAdminRenderQueueTable": {
|
||||||
|
"emptyState": "هیچ کار رندری برای فیلتر انتخابشده یافت نشد.",
|
||||||
|
"colJobId": "شناسه کار",
|
||||||
|
"colProject": "پروژه",
|
||||||
|
"colStep": "مرحله",
|
||||||
|
"colProgress": "پیشرفت",
|
||||||
|
"colQuality": "کیفیت",
|
||||||
|
"colNode": "نود",
|
||||||
|
"colCreated": "زمان ایجاد",
|
||||||
|
"colActions": "عملیات",
|
||||||
|
"actionRetry": "تلاش مجدد",
|
||||||
|
"actionCancel": "لغو"
|
||||||
|
},
|
||||||
|
"componentsAuthAuthPageContent": {
|
||||||
|
"genericError": "خطایی رخ داد. لطفاً دوباره تلاش کنید.",
|
||||||
|
"accountCreatedVerify": "حساب شما ساخته شد. برای تأیید، ایمیل خود را بررسی کنید و سپس وارد شوید.",
|
||||||
|
"accountCreatedSignIn": "حساب شما ساخته شد. لطفاً وارد شوید.",
|
||||||
|
"networkError": "خطای شبکه. لطفاً دوباره تلاش کنید.",
|
||||||
|
"resetCodeSent": "اگر این ایمیل ثبت شده باشد، کد بازنشانی برای شما ارسال شد.",
|
||||||
|
"invalidCode": "کد نامعتبر یا منقضیشده است.",
|
||||||
|
"passwordUpdated": "رمز عبور بهروزرسانی شد. اکنون میتوانید وارد شوید.",
|
||||||
|
"checkingAuth": "در حال بررسی احراز هویت...",
|
||||||
|
"resetTitle": "بازنشانی رمز عبور",
|
||||||
|
"enterCodeTitle": "وارد کردن کد بازنشانی",
|
||||||
|
"resetSubtitle": "یک کد یکبارمصرف به ایمیل شما ارسال میکنیم.",
|
||||||
|
"enterCodeSubtitle": "کد ارسالشده به {email} را در ایمیل خود بررسی کنید",
|
||||||
|
"emailAddressLabel": "نشانی ایمیل",
|
||||||
|
"sendResetCode": "ارسال کد بازنشانی",
|
||||||
|
"resetCodeLabel": "کد بازنشانی",
|
||||||
|
"resetCodePlaceholder": "کد ۶ رقمی",
|
||||||
|
"newPasswordLabel": "رمز عبور جدید",
|
||||||
|
"setNewPassword": "تنظیم رمز عبور جدید",
|
||||||
|
"backToSignIn": "بازگشت به ورود",
|
||||||
|
"welcomeTitle": "به فلترندر خوش آمدید",
|
||||||
|
"signInSubtitle": "برای ادامه به داشبورد خود وارد شوید",
|
||||||
|
"signUpSubtitle": "برای شروع یک حساب رایگان بسازید",
|
||||||
|
"signInTab": "ورود",
|
||||||
|
"signUpTab": "ثبتنام",
|
||||||
|
"emailLabel": "ایمیل",
|
||||||
|
"passwordLabel": "رمز عبور",
|
||||||
|
"forgotPassword": "رمز عبور را فراموش کردهاید؟",
|
||||||
|
"createAccount": "ساخت حساب",
|
||||||
|
"legalNotice": "با ادامه دادن، با <terms>قوانین</terms> و <privacy>سیاست حفظ حریم خصوصی</privacy> ما موافقت میکنید."
|
||||||
|
},
|
||||||
|
"componentsAuthSupabaseSetupNotice": {
|
||||||
|
"title": "Supabase پیکربندی نشده است",
|
||||||
|
"instructions": "فایل <envExample></envExample> را به <envLocal></envLocal> کپی کنید و مقادیر <supabaseUrl></supabaseUrl> و <supabaseAnonKey></supabaseAnonKey> را تنظیم کنید، سپس سرور توسعه را دوباره راهاندازی کنید.",
|
||||||
|
"continueDev": "ادامه بدون ورود (فقط حالت توسعه)",
|
||||||
|
"backToHome": "بازگشت به خانه"
|
||||||
|
},
|
||||||
|
"componentsDashboardDashboardEmptyState": {
|
||||||
|
"title": "هنوز پروژهای ندارید",
|
||||||
|
"description": "یک پروژه ویدیو، تصویر یا برش بسازید تا اینجا نمایش داده شود. هر چه ذخیره کنید در این فضای کاری ظاهر میشود.",
|
||||||
|
"createFirstProject": "اولین پروژه خود را بسازید"
|
||||||
|
},
|
||||||
|
"componentsDashboardDashboardPlanBadge": {
|
||||||
|
"upgradePlan": "ارتقای اشتراک"
|
||||||
|
},
|
||||||
|
"componentsDashboardDashboardProjectsSection": {
|
||||||
|
"recentProjects": "پروژههای اخیر",
|
||||||
|
"noResultsTitle": "هیچ پروژهای با جستجوی شما مطابقت ندارد",
|
||||||
|
"noResultsDescription": "کلمه کلیدی دیگری را امتحان کنید یا نوار جستجو را پاک کنید."
|
||||||
|
},
|
||||||
|
"componentsDashboardSettingsSettingsBilling": {
|
||||||
|
"title": "صورتحساب و اشتراک",
|
||||||
|
"subtitle": "اشتراک و روش پرداخت خود را مدیریت کنید.",
|
||||||
|
"currentPlan": "اشتراک فعلی",
|
||||||
|
"planFree": "رایگان",
|
||||||
|
"planPro": "حرفهای",
|
||||||
|
"planBusiness": "تجاری",
|
||||||
|
"statusCancelsAtPeriodEnd": "در پایان دوره لغو میشود",
|
||||||
|
"statusActive": "فعال",
|
||||||
|
"statusFreeTier": "نسخه رایگان",
|
||||||
|
"upgrade": "ارتقا",
|
||||||
|
"changePlan": "تغییر اشتراک",
|
||||||
|
"cancelPlan": "لغو اشتراک",
|
||||||
|
"cancelling": "در حال لغو…",
|
||||||
|
"cancelConfirm": "اشتراک خود را لغو میکنید؟ تا پایان دوره فعلی دسترسی شما حفظ میشود.",
|
||||||
|
"cancelFailed": "لغو اشتراک ناموفق بود. لطفاً دوباره تلاش کنید.",
|
||||||
|
"networkError": "خطای شبکه. لطفاً دوباره تلاش کنید.",
|
||||||
|
"cancelledNotice": "اشتراک شما لغو شد. تا پایان دوره صورتحساب، دسترسی شما حفظ میشود.",
|
||||||
|
"upgradeHint": "برای دسترسی به پروژههای نامحدود، خروجی ۴K و قالبهای ویژه، اشتراک خود را ارتقا دهید.",
|
||||||
|
"featureFree5Projects": "۵ پروژه",
|
||||||
|
"featureFree720pExport": "خروجی ۷۲۰p",
|
||||||
|
"featureFreeCommunityTemplates": "قالبهای عمومی",
|
||||||
|
"featureProUnlimitedProjects": "پروژههای نامحدود",
|
||||||
|
"featurePro4kExport": "خروجی ۴K",
|
||||||
|
"featureProAllTemplates": "همه قالبها",
|
||||||
|
"featureProPriorityRenderQueue": "صف رندر اولویتدار",
|
||||||
|
"featureProCustomFonts": "فونتهای سفارشی",
|
||||||
|
"featureBusinessEverythingInPro": "همه امکانات نسخه حرفهای",
|
||||||
|
"featureBusinessTeamSeats": "صندلیهای تیمی",
|
||||||
|
"featureBusinessWhiteLabelExport": "خروجی بدون برند",
|
||||||
|
"featureBusinessApiAccess": "دسترسی به API",
|
||||||
|
"featureBusinessDedicatedSupport": "پشتیبانی اختصاصی"
|
||||||
|
},
|
||||||
|
"componentsDashboardSettingsSettingsNotifications": {
|
||||||
|
"title": "اعلانها",
|
||||||
|
"subtitle": "انتخاب کنید چه ایمیلهایی از فلترندر دریافت کنید.",
|
||||||
|
"savePreferences": "ذخیره تنظیمات",
|
||||||
|
"saved": "ذخیره شد!",
|
||||||
|
"renderCompleteLabel": "اتمام رندر",
|
||||||
|
"renderCompleteDescription": "هنگام پایان خروجی گرفتن از ویدیو به شما اطلاع داده میشود.",
|
||||||
|
"projectSharedLabel": "اشتراکگذاری پروژه با شما",
|
||||||
|
"projectSharedDescription": "هنگامی که یکی از اعضای تیم پروژهای را با شما به اشتراک میگذارد.",
|
||||||
|
"weeklyDigestLabel": "خلاصه هفتگی",
|
||||||
|
"weeklyDigestDescription": "خلاصهای از قالبهای جدید و بهروزرسانیهای پلتفرم.",
|
||||||
|
"productNewsLabel": "اخبار محصول",
|
||||||
|
"productNewsDescription": "امکانات جدید، نکتهها و اطلاعیهها."
|
||||||
|
},
|
||||||
|
"componentsDashboardSettingsSettingsProfile": {
|
||||||
|
"title": "پروفایل",
|
||||||
|
"subtitle": "نام عمومی و ایمیل حساب شما.",
|
||||||
|
"displayNameLabel": "نام نمایشی",
|
||||||
|
"displayNamePlaceholder": "نام شما",
|
||||||
|
"emailLabel": "ایمیل",
|
||||||
|
"emailHint": "ایمیل را از اینجا نمیتوان تغییر داد. با پشتیبانی تماس بگیرید.",
|
||||||
|
"saving": "در حال ذخیره…",
|
||||||
|
"saveChanges": "ذخیره تغییرات",
|
||||||
|
"updateFailed": "بهروزرسانی پروفایل ممکن نشد.",
|
||||||
|
"updateSuccess": "پروفایل با موفقیت بهروزرسانی شد.",
|
||||||
|
"networkError": "خطای شبکه. لطفاً دوباره تلاش کنید."
|
||||||
|
},
|
||||||
|
"componentsDashboardSettingsSettingsSecurity": {
|
||||||
|
"title": "امنیت",
|
||||||
|
"subtitle": "رمز عبور حساب خود را تغییر دهید.",
|
||||||
|
"currentPasswordLabel": "رمز عبور فعلی",
|
||||||
|
"newPasswordLabel": "رمز عبور جدید",
|
||||||
|
"confirmPasswordLabel": "تکرار رمز عبور جدید",
|
||||||
|
"showPassword": "نمایش رمز عبور",
|
||||||
|
"hidePassword": "پنهان کردن رمز عبور",
|
||||||
|
"saving": "در حال ذخیره…",
|
||||||
|
"changePassword": "تغییر رمز عبور",
|
||||||
|
"errorMinLength": "رمز عبور جدید باید حداقل ۸ کاراکتر باشد.",
|
||||||
|
"errorMismatch": "رمزهای عبور مطابقت ندارند.",
|
||||||
|
"errorChangeFailed": "تغییر رمز عبور ممکن نشد.",
|
||||||
|
"changeSuccess": "رمز عبور با موفقیت تغییر کرد.",
|
||||||
|
"networkError": "خطای شبکه. لطفاً دوباره تلاش کنید."
|
||||||
|
},
|
||||||
|
"componentsImageMakerImageMakerBeforeAfter": {
|
||||||
|
"beforeAlt": "قبل از ویرایش",
|
||||||
|
"afterAlt": "بعد از ویرایش با هوش مصنوعی",
|
||||||
|
"beforeLabel": "قبل",
|
||||||
|
"afterLabel": "بعد",
|
||||||
|
"caption": "رنگ، چیدمان و استایل برند با هوش مصنوعی، تنها با یک کلیک اعمال میشود"
|
||||||
|
},
|
||||||
|
"componentsImageMakerImageMakerGallery": {
|
||||||
|
"title": "نمونههایی از ساختههای سازندگان",
|
||||||
|
"subtitle": "چیدمانها و سبکهای واقعی که میتوانید بازآفرینی کنید—یا از آنها برای پروژه بعدیتان الهام بگیرید."
|
||||||
|
},
|
||||||
|
"componentsLayoutNavbarMenuDropdown": {
|
||||||
|
"learn": "آموزش"
|
||||||
|
},
|
||||||
|
"componentsLayoutNavbarMobileMenu": {
|
||||||
|
"videoMaker": "ویدیوساز",
|
||||||
|
"imageMaker": "تصویرساز",
|
||||||
|
"pricing": "قیمتگذاری",
|
||||||
|
"learn": "آموزش"
|
||||||
|
},
|
||||||
|
"componentsSectionsHeroPreviewCards": {
|
||||||
|
"heading": "ساختهشده توسط طراحان موشنگرافیک در سطح جهانی",
|
||||||
|
"previewAriaLabel": "پیشنمایش {label}",
|
||||||
|
"template3dTitle": "کارخانه انیمیشنهای سهبعدی",
|
||||||
|
"templateWhiteboardTitle": "جعبهابزار انیمیشن وایتبردی",
|
||||||
|
"templateExplainerTitle": "جعبهابزار ویدیوی توضیحی سهبعدی",
|
||||||
|
"templateTrendyTitle": "جعبهابزار ویدیوی توضیحی ترِند"
|
||||||
|
},
|
||||||
|
"componentsSectionsPricingAnimatedPrice": {
|
||||||
|
"perMonth": "/ ماهانه"
|
||||||
|
},
|
||||||
|
"componentsSectionsPricingBillingToggle": {
|
||||||
|
"monthly": "ماهانه",
|
||||||
|
"yearly": "سالانه",
|
||||||
|
"savePercent": "{percent}٪ صرفهجویی",
|
||||||
|
"switchToYearly": "برای صرفهجویی بیشتر به پرداخت سالانه تغییر دهید"
|
||||||
|
},
|
||||||
|
"componentsSectionsPricingCard": {
|
||||||
|
"mostPopular": "محبوبترین"
|
||||||
|
},
|
||||||
|
"componentsTemplatesTemplateDetailExamples": {
|
||||||
|
"heading": "ویدیوهای ساختهشده با این قالب"
|
||||||
|
},
|
||||||
|
"componentsTemplatesTemplateDetailInfo": {
|
||||||
|
"sceneCount": "{count} صحنه",
|
||||||
|
"durationFlexible": "انعطافپذیر",
|
||||||
|
"durationFixed": "ثابت",
|
||||||
|
"fallbackDescription": "با این قالب حرفهای ویدیوهای چشمنواز بسازید. صحنهها را انتخاب کنید، متن را سفارشی کنید و در چند دقیقه خروجی بگیرید.",
|
||||||
|
"availableStyles": "سبکهای موجود ({count})",
|
||||||
|
"styleClassic": "کلاسیک",
|
||||||
|
"styleModern": "مدرن",
|
||||||
|
"styleBold": "پررنگ",
|
||||||
|
"styleMinimal": "مینیمال",
|
||||||
|
"createNow": "همین حالا بساز",
|
||||||
|
"removeFromFavorites": "حذف از علاقهمندیها",
|
||||||
|
"addToFavorites": "افزودن به علاقهمندیها",
|
||||||
|
"createError": "ساخت پروژه ممکن نشد: {error}"
|
||||||
|
},
|
||||||
|
"componentsTemplatesTemplateDetailPreview": {
|
||||||
|
"posterAlt": "پیشنمایش {name}",
|
||||||
|
"playPreview": "پخش پیشنمایش قالب"
|
||||||
|
},
|
||||||
|
"componentsTemplatesTemplateDetailRating": {
|
||||||
|
"starsAriaLabel": "{score} از ۵ ستاره",
|
||||||
|
"ratingsCount": "({count} امتیاز)"
|
||||||
|
},
|
||||||
|
"componentsTemplatesTemplatesActiveFilters": {
|
||||||
|
"removeFilter": "حذف فیلتر: {label}",
|
||||||
|
"searchLabel": "جستجو: «{query}»"
|
||||||
|
},
|
||||||
|
"componentsTemplatesVideoVideoTemplatesHero": {
|
||||||
|
"breadcrumbHome": "خانه",
|
||||||
|
"breadcrumbTemplates": "قالبها",
|
||||||
|
"title": "قالبهای ویدیویی برای هر نیازی",
|
||||||
|
"subtitle": "قالبهای ویدیویی قابلشخصیسازی را پیدا کنید. با ویدیوساز آنلاین فلترندر، تیزرهای انیمیشنی، نمایش لوگو، اسلایدشو و موارد دیگر بسازید."
|
||||||
|
},
|
||||||
|
"componentsTemplatesVideoVideoTemplatesPageContent": {
|
||||||
|
"openTemplateError": "باز کردن قالب ممکن نشد: {error}",
|
||||||
|
"emptyStateTitle": "هیچ قالبی با فیلترهای شما مطابقت ندارد",
|
||||||
|
"emptyStateDescription": "اندازه، دستهبندی یا عبارت جستوجوی دیگری را امتحان کنید."
|
||||||
|
},
|
||||||
|
"componentsTemplatesVideoVideoTemplatesToolbar": {
|
||||||
|
"searchPlaceholder": "جستوجو در هزاران قالب",
|
||||||
|
"sortByLabel": "مرتبسازی بر اساس:",
|
||||||
|
"sortAriaLabel": "مرتبسازی قالبها",
|
||||||
|
"sortTrending": "پرطرفدار",
|
||||||
|
"sortNewest": "جدیدترین",
|
||||||
|
"sortPopular": "محبوبترین"
|
||||||
|
},
|
||||||
|
"componentsTrimmerTrimmerExportSection": {
|
||||||
|
"heading": "خروجی",
|
||||||
|
"processing": "در حال پردازش…",
|
||||||
|
"trimAndCrop": "برش و کراپ",
|
||||||
|
"loadingEngine": "در حال بارگذاری موتور FFmpeg…",
|
||||||
|
"progress": "پیشرفت",
|
||||||
|
"download": "دانلود {format}"
|
||||||
|
},
|
||||||
|
"componentsTrimmerTrimmerStrip": {
|
||||||
|
"heading": "برش",
|
||||||
|
"trimStart": "شروع برش",
|
||||||
|
"trimEnd": "پایان برش"
|
||||||
|
},
|
||||||
|
"componentsTrimmerTrimmerUploadZone": {
|
||||||
|
"dropPrompt": "ویدیو را بکشید و رها کنید، یا برای انتخاب کلیک کنید",
|
||||||
|
"supportedFormats": "MP4، WebM، MOV و دیگر فرمتهای ویدیویی"
|
||||||
|
},
|
||||||
|
"componentsDashboardDashboardSidebar": {
|
||||||
|
"currentPlan": "پلن فعلی",
|
||||||
|
"signOut": "خروج از حساب"
|
||||||
|
},
|
||||||
|
"componentsDashboardDashboardSidebarNav": {
|
||||||
|
"myProjects": "پروژههای من",
|
||||||
|
"templates": "قالبها",
|
||||||
|
"upgrade": "ارتقا",
|
||||||
|
"settings": "تنظیمات",
|
||||||
|
"navLabel": "داشبورد"
|
||||||
|
},
|
||||||
|
"componentsDashboardDashboardTopBar": {
|
||||||
|
"searchPlaceholder": "جستجوی پروژهها..."
|
||||||
|
},
|
||||||
|
"componentsSectionsPricingCompareTable": {
|
||||||
|
"mostPopular": "محبوبترین",
|
||||||
|
"compareHeading": "مقایسه پلنها و امکانات",
|
||||||
|
"saveUpTo": "تا {percent}٪ صرفهجویی کنید"
|
||||||
|
},
|
||||||
|
"componentsSectionsPricingCreditsBanner": {
|
||||||
|
"refillCredits": "با داشتن یک پلن فعال میتوانید هر زمان که خواستید اعتبار هوش مصنوعی خود را شارژ کنید"
|
||||||
|
},
|
||||||
|
"componentsSectionsPricingFeatureList": {
|
||||||
|
"moreInformation": "اطلاعات بیشتر"
|
||||||
|
},
|
||||||
|
"componentsSectionsPricingFreeBanner": {
|
||||||
|
"title": "همیشه رایگان برای امتحان",
|
||||||
|
"description": "با پلن رایگان، CreatorStudio را تجربه کنید — ویدیوهای HD همراه با واترمارک بسازید، امکانات پایه را امتحان کنید و پیش از خرید اشتراک آزمایش کنید.",
|
||||||
|
"ctaLabel": "شروع کنید"
|
||||||
|
},
|
||||||
|
"componentsSectionsTemplateCard": {
|
||||||
|
"useTemplateLabel": "استفاده از قالب",
|
||||||
|
"openingLabel": "در حال باز کردن…",
|
||||||
|
"viewTemplateAriaLabel": "مشاهده قالب {name}"
|
||||||
|
},
|
||||||
|
"componentsSectionsTestimonialCard": {
|
||||||
|
"ratingLabel": "امتیاز ۵ از ۵ ستاره"
|
||||||
|
},
|
||||||
|
"componentsTemplatesTemplateDetailBreadcrumb": {
|
||||||
|
"breadcrumbAriaLabel": "مسیر راهنما",
|
||||||
|
"home": "خانه",
|
||||||
|
"templates": "قالبها"
|
||||||
|
},
|
||||||
|
"appImageMakerPage": {
|
||||||
|
"metaTitle": "ساخت تصویر با هوش مصنوعی",
|
||||||
|
"metaDescription": "تصاویر حرفهای را در لحظه با تولید هوشمند، قالبها، کیتهای برند و خروجی گروهی طراحی کنید."
|
||||||
|
},
|
||||||
|
"appPage": {
|
||||||
|
"metaTitle": "ساخت ویدیو و تصویر حرفهای با هوش مصنوعی",
|
||||||
|
"metaDescription": "فلترندر به سازندگان محتوا و برندها کمک میکند تا با قالبها، ویرایشگرها و خروجی تککلیکی هوش مصنوعی، ویدیو و تصویر حرفهای بسازند."
|
||||||
|
},
|
||||||
|
"componentsDashboardNewProjectMenu": {
|
||||||
|
"newProject": "پروژه جدید",
|
||||||
|
"creating": "در حال ساخت…",
|
||||||
|
"videoProject": "پروژه ویدیویی",
|
||||||
|
"imageProject": "پروژه تصویری",
|
||||||
|
"trimCropVideo": "برش/کراپ ویدیو"
|
||||||
|
},
|
||||||
|
"componentsDashboardProjectCard": {
|
||||||
|
"openInStudio": "باز کردن در استودیو",
|
||||||
|
"download": "دانلود",
|
||||||
|
"rename": "تغییر نام",
|
||||||
|
"duplicate": "ایجاد نسخه مشابه",
|
||||||
|
"delete": "حذف",
|
||||||
|
"statusRendering": "در حال رندر",
|
||||||
|
"statusReady": "آماده",
|
||||||
|
"statusDraft": "پیشنویس",
|
||||||
|
"actionsFor": "عملیات برای {name}"
|
||||||
|
},
|
||||||
|
"componentsSectionsPricingCheckoutButton": {
|
||||||
|
"checkoutFailed": "پرداخت ناموفق بود.",
|
||||||
|
"noCheckoutUrl": "آدرس پرداخت دریافت نشد."
|
||||||
|
},
|
||||||
|
"componentsTemplatesTemplatesSidebar": {
|
||||||
|
"categoryHeading": "دستهبندی",
|
||||||
|
"styleHeading": "سبک",
|
||||||
|
"colorHeading": "رنگ"
|
||||||
|
},
|
||||||
|
"componentsTemplatesVideoVideoTemplateCompactCard": {
|
||||||
|
"viewTemplateAria": "مشاهده قالب {name}",
|
||||||
|
"opening": "در حال باز شدن…",
|
||||||
|
"useTemplate": "استفاده از قالب",
|
||||||
|
"sceneCount": "{count} صحنه"
|
||||||
|
},
|
||||||
|
"componentsTemplatesVideoVideoTemplatesCarouselRow": {
|
||||||
|
"seeAll": "مشاهده همه",
|
||||||
|
"scrollLeftAria": "اسکرول {title} به چپ",
|
||||||
|
"scrollRightAria": "اسکرول {title} به راست"
|
||||||
|
},
|
||||||
|
"componentsTemplatesVideoVideoTemplatesCategorySidebar": {
|
||||||
|
"categoriesNavLabel": "دستهبندی قالبها",
|
||||||
|
"categoryAll": "همه قالبها",
|
||||||
|
"categoryAnimation": "ویدیوهای انیمیشن",
|
||||||
|
"categoryIntros": "اینترو و لوگو",
|
||||||
|
"categoryEditing": "تدوین ویدیو",
|
||||||
|
"categoryInvitation": "ویدیوهای دعوت",
|
||||||
|
"categoryHoliday": "ویدیوهای مناسبتی",
|
||||||
|
"categorySlideshow": "اسلایدشو",
|
||||||
|
"categoryPresentations": "ارائهها",
|
||||||
|
"categorySocial": "ویدیوهای شبکههای اجتماعی",
|
||||||
|
"categoryAds": "قالبهای تبلیغاتی ویدیویی",
|
||||||
|
"categorySales": "ویدیوهای فروش",
|
||||||
|
"categoryMusic": "ویژوال موزیک",
|
||||||
|
"filters": "فیلترها",
|
||||||
|
"sizeLabel": "اندازه"
|
||||||
|
},
|
||||||
|
"componentsTemplatesVideoVideoTemplatesFilterControls": {
|
||||||
|
"premiumOnly": "فقط ویژه",
|
||||||
|
"premiumOnlyAriaLabel": "فقط ویژه",
|
||||||
|
"sizeAriaLabel": "اندازه قالب",
|
||||||
|
"sizePlaceholder": "همه اندازهها"
|
||||||
|
},
|
||||||
|
"componentsTrimmerTrimmerVideoPreview": {
|
||||||
|
"previewAndCrop": "پیشنمایش و برش",
|
||||||
|
"aspectFree": "آزاد",
|
||||||
|
"aspect16x9": "۱۶:۹",
|
||||||
|
"aspect9x16": "۹:۱۶",
|
||||||
|
"aspect1x1": "۱:۱",
|
||||||
|
"aspect4x3": "۴:۳"
|
||||||
|
},
|
||||||
|
"componentsVideoMakerVideoMakerEditorPreview": {
|
||||||
|
"appBarTitle": "کریتور استودیو — ویرایشگر ویدیو",
|
||||||
|
"sceneCaption": "صحنه ۲ · معرفی محصول · ۰۰:۱۲",
|
||||||
|
"layersHeading": "لایهها",
|
||||||
|
"layerIntroTitle": "عنوان آغازین",
|
||||||
|
"layerBrollClip": "کلیپ مکمل",
|
||||||
|
"layerBackgroundMusic": "موسیقی پسزمینه",
|
||||||
|
"layerCaptions": "زیرنویسها"
|
||||||
|
},
|
||||||
|
"componentsVideoMakerVideoMakerTemplateCarousel": {
|
||||||
|
"title": "قالبهای ویدیویی برای هر داستان",
|
||||||
|
"subtitle": "از یک طرح آماده شروع کنید و در چند دقیقه صحنهها، متن و موسیقی را شخصیسازی کنید.",
|
||||||
|
"templatePromo": "تبلیغ محصول",
|
||||||
|
"templateYoutube": "اینترو یوتیوب",
|
||||||
|
"templateReel": "قلاب ریلز",
|
||||||
|
"templateCorporate": "خبر سازمانی",
|
||||||
|
"templateAd": "نمایش تبلیغاتی",
|
||||||
|
"templateTutorial": "آموزشی",
|
||||||
|
"templateEvent": "جمعبندی رویداد",
|
||||||
|
"templateTestimonial": "روایت مشتری"
|
||||||
|
},
|
||||||
|
"componentsImageEditorAiRemoveBgModal": {
|
||||||
|
"openImageFirst": "ابتدا یک تصویر باز کنید.",
|
||||||
|
"removalFailed": "حذف پسزمینه ناموفق بود.",
|
||||||
|
"backgroundRemoved": "پسزمینه حذف شد!",
|
||||||
|
"serviceUnreachable": "دسترسی به سرویس حذف پسزمینه ممکن نشد.",
|
||||||
|
"title": "حذف پسزمینه با هوش مصنوعی",
|
||||||
|
"description": "پسزمینه را از تصویر پایه حذف کنید. نتیجه، لایه پسزمینه را با یک PNG شفاف جایگزین میکند.",
|
||||||
|
"processing": "در حال پردازش…",
|
||||||
|
"removeBackground": "حذف پسزمینه"
|
||||||
|
},
|
||||||
|
"componentsImageEditorImageCropControls": {
|
||||||
|
"aspectFree": "آزاد",
|
||||||
|
"cancel": "انصراف",
|
||||||
|
"applying": "در حال اعمال…",
|
||||||
|
"applyCrop": "اعمال برش"
|
||||||
|
},
|
||||||
|
"componentsImageEditorImageEditorRightPanel": {
|
||||||
|
"tabAdjust": "تنظیمات",
|
||||||
|
"tabFilters": "فیلترها",
|
||||||
|
"tabLayers": "لایهها"
|
||||||
|
},
|
||||||
|
"componentsImageEditorImageEditorToolbar": {
|
||||||
|
"toolSelect": "انتخاب",
|
||||||
|
"toolCrop": "برش",
|
||||||
|
"toolText": "متن",
|
||||||
|
"toolShape": "شکل",
|
||||||
|
"toolDraw": "ترسیم",
|
||||||
|
"toolAi": "هوش مصنوعی",
|
||||||
|
"shapeRectangle": "مستطیل",
|
||||||
|
"shapeCircle": "دایره",
|
||||||
|
"shapeLine": "خط",
|
||||||
|
"shapeArrow": "پیکان"
|
||||||
|
},
|
||||||
|
"componentsImageEditorImageEditorTopBar": {
|
||||||
|
"defaultProjectName": "ویرایشگر تصویر",
|
||||||
|
"open": "باز کردن",
|
||||||
|
"export": "خروجی گرفتن",
|
||||||
|
"format": "فرمت",
|
||||||
|
"quality": "کیفیت",
|
||||||
|
"download": "دانلود",
|
||||||
|
"canvasNotReady": "بوم آماده نیست.",
|
||||||
|
"exportStarted": "خروجیگیری آغاز شد"
|
||||||
|
},
|
||||||
|
"componentsImageEditorPanelsAdjustPanel": {
|
||||||
|
"emptyState": "برای استفاده از تنظیمات، یک تصویر باز کنید.",
|
||||||
|
"brightness": "روشنایی",
|
||||||
|
"contrast": "کنتراست",
|
||||||
|
"saturation": "اشباع رنگ",
|
||||||
|
"hue": "تهرنگ",
|
||||||
|
"blur": "محو شدگی",
|
||||||
|
"sharpen": "وضوح",
|
||||||
|
"vignette": "وینیت"
|
||||||
|
},
|
||||||
|
"componentsImageEditorPanelsFiltersPanel": {
|
||||||
|
"emptyState": "برای اعمال فیلترها یک تصویر باز کنید."
|
||||||
|
},
|
||||||
|
"componentsImageEditorPanelsLayersPanel": {
|
||||||
|
"reorderLayer": "تغییر ترتیب {name}",
|
||||||
|
"hideLayer": "پنهان کردن لایه",
|
||||||
|
"showLayer": "نمایش لایه",
|
||||||
|
"deleteLayer": "حذف {name}",
|
||||||
|
"emptyState": "هنوز لایهای وجود ندارد."
|
||||||
|
},
|
||||||
|
"componentsStudioAddSceneMenu": {
|
||||||
|
"addScene": "افزودن صحنه",
|
||||||
|
"blankScene": "صحنه خالی",
|
||||||
|
"fromTemplate": "از روی قالب"
|
||||||
|
},
|
||||||
|
"componentsStudioDraggableSceneItem": {
|
||||||
|
"dragScene": "جابجایی صحنه {name}",
|
||||||
|
"sceneNameLabel": "نام صحنه"
|
||||||
|
},
|
||||||
|
"componentsStudioProjectSaveIndicator": {
|
||||||
|
"saving": "در حال ذخیره…",
|
||||||
|
"saved": "ذخیره شد",
|
||||||
|
"localSave": "ذخیره محلی",
|
||||||
|
"saveFailed": "ذخیره ناموفق بود",
|
||||||
|
"retry": "تلاش مجدد"
|
||||||
|
},
|
||||||
|
"componentsStudioPropertiesPanel": {
|
||||||
|
"title": "ویژگیها",
|
||||||
|
"emptyState": "برای ویرایش ویژگیها یک لایه را انتخاب کنید",
|
||||||
|
"layerLabel": "لایه {type}"
|
||||||
|
},
|
||||||
|
"componentsStudioRenderModal": {
|
||||||
|
"dialogTitle": "خروجی گرفتن",
|
||||||
|
"dialogDescription": "پروژه خود را از طریق خط پردازش nexrender به صورت MP4 خروجی بگیرید.",
|
||||||
|
"videoReady": "ویدیوی شما آماده است.",
|
||||||
|
"downloadMp4": "دانلود MP4",
|
||||||
|
"shareLink": "اشتراکگذاری لینک",
|
||||||
|
"close": "بستن",
|
||||||
|
"errorGeneric": "مشکلی پیش آمد.",
|
||||||
|
"retry": "تلاش دوباره",
|
||||||
|
"previewAlt": "پیشنمایش رندر",
|
||||||
|
"rendering": "در حال رندر…",
|
||||||
|
"progress": "پیشرفت",
|
||||||
|
"resolution": "وضوح تصویر",
|
||||||
|
"format": "فرمت",
|
||||||
|
"fps": "فریم بر ثانیه",
|
||||||
|
"startRendering": "شروع رندر",
|
||||||
|
"errorFetchStatus": "دریافت وضعیت رندر امکانپذیر نبود.",
|
||||||
|
"renderingProgress": "در حال رندر… {progress}٪",
|
||||||
|
"errorRenderFailed": "رندر ناموفق بود.",
|
||||||
|
"errorNetworkPolling": "خطای شبکه هنگام بررسی وضعیت.",
|
||||||
|
"errorStartRender": "شروع رندر ناموفق بود.",
|
||||||
|
"queued": "در صف رندر قرار گرفت…",
|
||||||
|
"errorReachApi": "دسترسی به سرویس رندر امکانپذیر نبود."
|
||||||
|
},
|
||||||
|
"componentsStudioSceneBrowserCard": {
|
||||||
|
"selectCta": "انتخاب"
|
||||||
|
},
|
||||||
|
"componentsStudioSceneBrowserModal": {
|
||||||
|
"title": "انتخاب صحنهها",
|
||||||
|
"closeAriaLabel": "بستن",
|
||||||
|
"filterAll": "همه",
|
||||||
|
"filterVideo": "ویدیو",
|
||||||
|
"filterPhoto": "عکس",
|
||||||
|
"searchPlaceholder": "جستجوی صحنهها...",
|
||||||
|
"emptyState": "هیچ صحنهای با فیلترهای شما مطابقت ندارد.",
|
||||||
|
"selectedSuffix": "{count, plural, one {صحنه انتخاب شد} other {صحنه انتخاب شد}}",
|
||||||
|
"deselectAll": "لغو انتخاب همه",
|
||||||
|
"cancel": "انصراف",
|
||||||
|
"addToVideo": "افزودن به ویدیو",
|
||||||
|
"addToVideoCount": "افزودن به ویدیو ({count})"
|
||||||
|
},
|
||||||
|
"componentsStudioSceneItemActions": {
|
||||||
|
"duplicate": "تکثیر {sceneName}",
|
||||||
|
"delete": "حذف {sceneName}"
|
||||||
|
},
|
||||||
|
"componentsStudioSceneTransitionPicker": {
|
||||||
|
"transition": "گذار"
|
||||||
|
},
|
||||||
|
"componentsStudioStudioMobileGate": {
|
||||||
|
"titleVideo": "استودیوی ویدیو به مرورگر دسکتاپ نیاز دارد.",
|
||||||
|
"titleImage": "ویرایشگر تصویر به مرورگر دسکتاپ نیاز دارد.",
|
||||||
|
"description": "لطفاً این پروژه را روی رایانه رومیزی یا لپتاپ باز کنید.",
|
||||||
|
"dashboardCta": "رفتن به داشبورد"
|
||||||
|
},
|
||||||
|
"componentsStudioStudioToolbar": {
|
||||||
|
"defaultText": "این متن را ویرایش کنید",
|
||||||
|
"addText": "افزودن متن",
|
||||||
|
"addImage": "افزودن تصویر",
|
||||||
|
"addVideoClip": "افزودن کلیپ ویدیویی",
|
||||||
|
"addShape": "افزودن شکل",
|
||||||
|
"shapeRectangle": "مستطیل",
|
||||||
|
"shapeCircle": "دایره",
|
||||||
|
"shapeLine": "خط",
|
||||||
|
"shapeArrow": "پیکان"
|
||||||
|
},
|
||||||
|
"componentsStudioCanvasVideoLayerNode": {
|
||||||
|
"defaultFileName": "ویدیو",
|
||||||
|
"placeholder": "کلیپ ویدیویی"
|
||||||
|
},
|
||||||
|
"componentsStudioPropertiesCommonLayerControls": {
|
||||||
|
"transformTitle": "تبدیل",
|
||||||
|
"widthLabel": "عرض",
|
||||||
|
"heightLabel": "ارتفاع",
|
||||||
|
"rotationLabel": "چرخش (°)",
|
||||||
|
"layerOrderTitle": "ترتیب لایهها",
|
||||||
|
"toFront": "انتقال به جلو",
|
||||||
|
"toBack": "انتقال به عقب",
|
||||||
|
"deleteLayer": "حذف لایه"
|
||||||
|
},
|
||||||
|
"componentsStudioPropertiesImageLayerProperties": {
|
||||||
|
"sectionTitle": "تصویر",
|
||||||
|
"opacity": "شفافیت",
|
||||||
|
"flipHorizontal": "وارونه افقی",
|
||||||
|
"flipVertical": "وارونه عمودی",
|
||||||
|
"replaceImage": "جایگزینی تصویر",
|
||||||
|
"borderRadius": "گردی گوشهها"
|
||||||
|
},
|
||||||
|
"componentsStudioPropertiesPropertyControls": {
|
||||||
|
"lockAspectRatio": "قفل نسبت ابعاد",
|
||||||
|
"unlockAspectRatio": "باز کردن قفل نسبت ابعاد"
|
||||||
|
},
|
||||||
|
"componentsStudioPropertiesShapeLayerProperties": {
|
||||||
|
"sectionTitle": "شکل",
|
||||||
|
"fillColor": "رنگ پرکننده",
|
||||||
|
"strokeColor": "رنگ خط دور",
|
||||||
|
"strokeWidth": "ضخامت خط دور",
|
||||||
|
"borderRadius": "گردی گوشهها",
|
||||||
|
"opacity": "شفافیت"
|
||||||
|
},
|
||||||
|
"componentsStudioPropertiesTextLayerProperties": {
|
||||||
|
"sectionTitle": "متن",
|
||||||
|
"fontFamily": "خانواده فونت",
|
||||||
|
"fontSize": "اندازه فونت",
|
||||||
|
"bold": "ضخیم",
|
||||||
|
"italic": "مورب",
|
||||||
|
"underline": "زیرخط",
|
||||||
|
"textColor": "رنگ متن",
|
||||||
|
"alignment": "تراز",
|
||||||
|
"alignLeft": "چپچین",
|
||||||
|
"alignCenter": "وسطچین",
|
||||||
|
"alignRight": "راستچین",
|
||||||
|
"letterSpacing": "فاصله حروف",
|
||||||
|
"lineHeight": "ارتفاع خط",
|
||||||
|
"opacity": "شفافیت",
|
||||||
|
"animation": "انیمیشن"
|
||||||
|
},
|
||||||
|
"componentsStudioSidebarAudioSidebarContent": {
|
||||||
|
"musicTab": "موسیقی",
|
||||||
|
"voiceoverTab": "صداگذاری"
|
||||||
|
},
|
||||||
|
"componentsStudioSidebarAudioSidebarMusicTab": {
|
||||||
|
"upload": "بارگذاری",
|
||||||
|
"includeTemplateSfx": "افزودن جلوه صوتی قالب",
|
||||||
|
"searchPlaceholder": "جستجوی موسیقی",
|
||||||
|
"musicLibrary": "کتابخانه موسیقی",
|
||||||
|
"myMusic": "موسیقیهای من",
|
||||||
|
"uploadOwnMusic": "موسیقی خود را بارگذاری کنید"
|
||||||
|
},
|
||||||
|
"componentsStudioSidebarAudioSidebarVoiceoverPane": {
|
||||||
|
"comingSoon": "بهزودی",
|
||||||
|
"description": "صداگذاری را مستقیماً از روی متن خود در استودیو بسازید."
|
||||||
|
},
|
||||||
|
"componentsStudioSidebarColorsCustomTab": {
|
||||||
|
"mainColor": "رنگ اصلی",
|
||||||
|
"additionalColor": "رنگ مکمل",
|
||||||
|
"applyToAllScenes": "اعمال به همه صحنهها"
|
||||||
|
},
|
||||||
|
"componentsStudioSidebarColorsPalettesTab": {
|
||||||
|
"paletteFallback": "پالت {number}",
|
||||||
|
"applyPaletteAriaLabel": "اعمال پالت {name}"
|
||||||
|
},
|
||||||
|
"componentsStudioSidebarColorsSidebarContent": {
|
||||||
|
"palettesTab": "پالتها",
|
||||||
|
"customTab": "سفارشی"
|
||||||
|
},
|
||||||
|
"componentsStudioSidebarColorsTemplatePreviewCard": {
|
||||||
|
"mainColor": "رنگ اصلی",
|
||||||
|
"additional": "رنگ مکمل",
|
||||||
|
"paletteFallback": "پالت {number}"
|
||||||
|
},
|
||||||
|
"componentsStudioSidebarFontSidebarContent": {
|
||||||
|
"title": "فونت",
|
||||||
|
"fontFamily": "خانواده فونت",
|
||||||
|
"applyToAll": "اعمال روی همه لایههای متنی"
|
||||||
|
},
|
||||||
|
"componentsStudioSidebarSceneEditSidebarContent": {
|
||||||
|
"panelTitle": "ویرایش صحنه",
|
||||||
|
"titleLabel": "عنوان",
|
||||||
|
"subtitleLabel": "زیرعنوان",
|
||||||
|
"textLabel": "متن {index}",
|
||||||
|
"textPlaceholder": "اینجا بنویسید…",
|
||||||
|
"imageLabel": "تصویر {index}",
|
||||||
|
"emptyStateTitle": "این صحنه هنوز محتوایی ندارد.",
|
||||||
|
"emptyStateHint": "برای شروع ویرایش، یک لایه متن اضافه کنید.",
|
||||||
|
"addTextLayer": "افزودن لایه متن",
|
||||||
|
"defaultText": "متن شما اینجا",
|
||||||
|
"replaceImage": "جایگزینی تصویر",
|
||||||
|
"uploadImage": "بارگذاری تصویر"
|
||||||
|
},
|
||||||
|
"componentsStudioSidebarTransitionsSidebarContent": {
|
||||||
|
"heading": "ترانزیشنها",
|
||||||
|
"randomTransition": "ترانزیشن تصادفی",
|
||||||
|
"noTransition": "بدون ترانزیشن",
|
||||||
|
"exportNote": "ترانزیشنهای اعمالشده پس از خروجی گرفتن روی همه صحنهها نمایش داده میشوند."
|
||||||
|
},
|
||||||
|
"componentsStudioSidebarTtsSidebarContent": {
|
||||||
|
"title": "تبدیل متن به گفتار",
|
||||||
|
"comingSoon": "بهزودی",
|
||||||
|
"description": "صداگذاری روایت را مستقیماً از روی متن خود در استودیو بسازید."
|
||||||
|
},
|
||||||
|
"componentsStudioSidebarWatermarkSidebarContent": {
|
||||||
|
"title": "واترمارک من",
|
||||||
|
"applyToAllScenes": "اعمال روی همه صحنهها",
|
||||||
|
"uploadLogo": "لوگوی واترمارک خود را بارگذاری کنید",
|
||||||
|
"uploadHint": "PNG یا SVG، حداکثر ۲ مگابایت",
|
||||||
|
"position": "موقعیت",
|
||||||
|
"positionTopLeft": "بالا چپ",
|
||||||
|
"positionTopCenter": "بالا وسط",
|
||||||
|
"positionTopRight": "بالا راست",
|
||||||
|
"positionMiddleLeft": "میانه چپ",
|
||||||
|
"positionCenter": "وسط",
|
||||||
|
"positionMiddleRight": "میانه راست",
|
||||||
|
"positionBottomLeft": "پایین چپ",
|
||||||
|
"positionBottomCenter": "پایین وسط",
|
||||||
|
"positionBottomRight": "پایین راست",
|
||||||
|
"opacity": "شفافیت",
|
||||||
|
"opacityAriaLabel": "شفافیت واترمارک"
|
||||||
|
},
|
||||||
|
"componentsStudioTimelineAudioTrack": {
|
||||||
|
"emptyState": "بدون صدا — برای افزودن کلیک کنید"
|
||||||
|
},
|
||||||
|
"componentsStudioTimelineSceneBlock": {
|
||||||
|
"resizeDuration": "تغییر مدتزمان {name}"
|
||||||
|
},
|
||||||
|
"componentsStudioTimelineSceneThumbnailBlock": {
|
||||||
|
"duplicateScene": "تکثیر {name}",
|
||||||
|
"deleteScene": "حذف {name}",
|
||||||
|
"resizeSceneDuration": "تغییر مدت زمان {name}",
|
||||||
|
"sceneNameLabel": "نام صحنه",
|
||||||
|
"doubleClickToRename": "برای تغییر نام، دوبار کلیک کنید"
|
||||||
|
},
|
||||||
|
"componentsStudioTimelineSceneThumbnailStrip": {
|
||||||
|
"browseScenes": "مرور صحنهها",
|
||||||
|
"addScene": "افزودن صحنه"
|
||||||
|
},
|
||||||
|
"componentsStudioTimelineTimeRuler": {
|
||||||
|
"rulerAriaLabel": "خطکش زمان — برای جابهجایی کلیک کنید"
|
||||||
|
},
|
||||||
|
"componentsStudioTimelineTimelineActionRow": {
|
||||||
|
"addTextToSpeech": "افزودن تبدیل متن به گفتار",
|
||||||
|
"addAudio": "افزودن صدا"
|
||||||
|
},
|
||||||
|
"componentsStudioTimelineTimelineControlBar": {
|
||||||
|
"copyLayer": "کپی لایه",
|
||||||
|
"deleteLayer": "حذف لایه",
|
||||||
|
"stop": "توقف",
|
||||||
|
"preview": "پیشنمایش",
|
||||||
|
"previewFromStart": "پیشنمایش از ابتدا",
|
||||||
|
"seekToStart": "رفتن به ابتدا",
|
||||||
|
"zoomOut": "کوچکنمایی",
|
||||||
|
"zoomIn": "بزرگنمایی",
|
||||||
|
"timelineZoom": "بزرگنمایی خط زمان"
|
||||||
|
},
|
||||||
|
"componentsStudioTimelineTimelineQuickActions": {
|
||||||
|
"addTextToSpeech": "افزودن تبدیل متن به گفتار",
|
||||||
|
"addAudio": "افزودن صدا"
|
||||||
|
},
|
||||||
|
"componentsStudioVideoCanvasArea": {
|
||||||
|
"loading": "در حال بارگذاری بوم…",
|
||||||
|
"editingNotice": "شما در حالت ویرایش هستید — ممکن است ظاهر متفاوت به نظر برسد. برای دیدن نتیجه نهایی روی <preview>پیشنمایش</preview> بزنید."
|
||||||
|
},
|
||||||
|
"componentsStudioVideoStudioSidebarDock": {
|
||||||
|
"scenes": "صحنهها",
|
||||||
|
"audio": "صدا",
|
||||||
|
"textToSpeech": "تبدیل متن به گفتار",
|
||||||
|
"colors": "رنگها",
|
||||||
|
"transitions": "گذارها",
|
||||||
|
"font": "فونت",
|
||||||
|
"myWatermark": "واترمارک من",
|
||||||
|
"toolsNavLabel": "ابزارهای استودیو",
|
||||||
|
"guideMe": "راهنماییام کن",
|
||||||
|
"guideComingSoon": "👋 راهنما بهزودی ارائه میشود!",
|
||||||
|
"keyboardShortcuts": "میانبرهای صفحهکلید",
|
||||||
|
"keyboardShortcutsComingSoon": "میانبرهای صفحهکلید بهزودی ارائه میشوند!"
|
||||||
|
},
|
||||||
|
"componentsStudioVideoStudioTopBar": {
|
||||||
|
"snapshotSaved": "اسنپشات ذخیره شد!",
|
||||||
|
"canvasNotReady": "بوم آماده نیست. دوباره تلاش کنید.",
|
||||||
|
"homeLink": "خانه فلترندر",
|
||||||
|
"breadcrumb": "مسیر",
|
||||||
|
"myProjects": "پروژههای من",
|
||||||
|
"projectName": "نام پروژه",
|
||||||
|
"undo": "واگرد",
|
||||||
|
"redo": "ازنو",
|
||||||
|
"stop": "توقف",
|
||||||
|
"preview": "پیشنمایش",
|
||||||
|
"takeSnapshot": "گرفتن اسنپشات",
|
||||||
|
"export": "خروجی گرفتن"
|
||||||
|
},
|
||||||
|
"componentsStudioVideoStudioTopBarSaveBadge": {
|
||||||
|
"savingTitle": "در حال ذخیره…",
|
||||||
|
"savingLabel": "در حال ذخیره",
|
||||||
|
"errorTitle": "ذخیره ناموفق بود",
|
||||||
|
"errorLabel": "ذخیره ناموفق بود",
|
||||||
|
"local": "محلی",
|
||||||
|
"saved": "ذخیره شد ✓"
|
||||||
|
},
|
||||||
|
"componentsStudioVideoStudioTopBarTextControls": {
|
||||||
|
"groupLabel": "ویژگیهای لایه متن",
|
||||||
|
"fontFamily": "نوع قلم",
|
||||||
|
"fontSize": "اندازه قلم",
|
||||||
|
"bold": "ضخیم",
|
||||||
|
"italic": "مورب",
|
||||||
|
"textColor": "رنگ متن"
|
||||||
|
},
|
||||||
|
"componentsStudioVideoVideoNewPresetCard": {
|
||||||
|
"useTemplate": "استفاده از قالب"
|
||||||
|
},
|
||||||
|
"componentsStudioVideoVideoProjectNewContent": {
|
||||||
|
"breadcrumbCreate": "ساخت ویدیوی جدید",
|
||||||
|
"heading": "برای شروع ساخت، یکی از گزینهها را انتخاب کنید",
|
||||||
|
"selectScenesTitle": "انتخاب صحنهها",
|
||||||
|
"selectScenesDescription": "صحنهها را مرور کنید و پروژهتان را از ابتدا بسازید",
|
||||||
|
"createWithAiTitle": "ساخت با هوش مصنوعی",
|
||||||
|
"createWithAiDescription": "ایدهها یا متن خود را بهسادگی به ویدیوهای ساختهشده با هوش مصنوعی تبدیل کنید",
|
||||||
|
"aiProjectName": "پروژه ویدیویی هوش مصنوعی",
|
||||||
|
"or": "یا",
|
||||||
|
"startWithPresets": "شروع با قالبهای آماده",
|
||||||
|
"searchPresetsPlaceholder": "جستجوی قالبهای آماده...",
|
||||||
|
"newVideoName": "ویدیوی جدید"
|
||||||
|
},
|
||||||
|
"adminAi": {
|
||||||
|
"pageTitle": "محتوای سئو با هوش مصنوعی",
|
||||||
|
"pageDesc": "OpenAI را پیکربندی کنید و از روی یک توضیح، مقالههای بهینهشده برای سئو بسازید.",
|
||||||
|
"settingsTitle": "پیکربندی OpenAI",
|
||||||
|
"settingsDesc": "کلید API شما بهصورت امن ذخیره میشود و هرگز بهطور کامل نمایش داده نمیشود. در صورت نیاز، آدرس پایه را به یک سرویس سازگار با OpenAI و دردسترس تنظیم کنید.",
|
||||||
|
"apiKeyLabel": "کلید API",
|
||||||
|
"apiKeyPlaceholder": "sk-… (برای حفظ مقدار فعلی خالی بگذارید)",
|
||||||
|
"baseUrlLabel": "آدرس پایه",
|
||||||
|
"modelLabel": "مدل",
|
||||||
|
"enabledLabel": "فعالسازی تولید با هوش مصنوعی",
|
||||||
|
"saveSettings": "ذخیره تنظیمات",
|
||||||
|
"saving": "در حال ذخیره…",
|
||||||
|
"settingsSaved": "تنظیمات ذخیره شد",
|
||||||
|
"settingsError": "ذخیره تنظیمات ناموفق بود",
|
||||||
|
"keyConfigured": "کلید API تنظیم شده است",
|
||||||
|
"noKey": "کلید API تنظیم نشده است",
|
||||||
|
"generateTitle": "تولید مقاله سئو",
|
||||||
|
"generateDesc": "موضوع و متادیتا را توصیف کنید تا هوش مصنوعی یک پست آمادهی سئو بنویسد.",
|
||||||
|
"descriptionLabel": "توضیح / خلاصه",
|
||||||
|
"descriptionPlaceholder": "این صفحه/محصول درباره چیست؟ نکات کلیدی، لحن، اهداف…",
|
||||||
|
"titleLabel": "عنوان پیشنهادی (اختیاری)",
|
||||||
|
"typeLabel": "نوع محتوا (اختیاری)",
|
||||||
|
"typePlaceholder": "مثلاً قالب ویدیویی",
|
||||||
|
"tagsLabel": "برچسبها (جداشده با کاما، اختیاری)",
|
||||||
|
"keywordLabel": "کلیدواژه اصلی (اختیاری)",
|
||||||
|
"audienceLabel": "مخاطب (اختیاری)",
|
||||||
|
"localeLabel": "زبان",
|
||||||
|
"localeFa": "فارسی",
|
||||||
|
"localeEn": "انگلیسی",
|
||||||
|
"generate": "تولید",
|
||||||
|
"generating": "در حال تولید…",
|
||||||
|
"generateError": "تولید ناموفق بود",
|
||||||
|
"resultTitle": "مقاله تولیدشده",
|
||||||
|
"fTitle": "عنوان",
|
||||||
|
"fSlug": "نامک",
|
||||||
|
"fMetaTitle": "عنوان متا",
|
||||||
|
"fMetaDesc": "توضیحات متا",
|
||||||
|
"fKeywords": "کلیدواژهها",
|
||||||
|
"fShortDesc": "توضیح کوتاه",
|
||||||
|
"fContent": "محتوا (HTML)",
|
||||||
|
"preview": "پیشنمایش",
|
||||||
|
"publishNow": "انتشار فوری",
|
||||||
|
"saveAsBlog": "ذخیره بهعنوان پست بلاگ",
|
||||||
|
"savedAsBlog": "بهعنوان پست بلاگ ذخیره شد",
|
||||||
|
"saveError": "ذخیره پست ناموفق بود",
|
||||||
|
"mustConfigure": "پیش از تولید، OpenAI را در بالا پیکربندی و فعال کنید."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-6
@@ -25,12 +25,12 @@ const nextConfig = {
|
|||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
// Placeholder art is now a same-origin SVG from /api/placeholder (offline-safe).
|
||||||
{
|
// dangerouslyAllowSVG only ever serves our own generated gradients — never user
|
||||||
protocol: "https",
|
// uploads — and the CSP + attachment disposition neutralise any script content.
|
||||||
hostname: "picsum.photos",
|
dangerouslyAllowSVG: true,
|
||||||
},
|
contentDispositionType: "attachment",
|
||||||
],
|
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
|
||||||
},
|
},
|
||||||
// Required for ffmpeg.wasm (SharedArrayBuffer needs COOP + COEP headers)
|
// Required for ffmpeg.wasm (SharedArrayBuffer needs COOP + COEP headers)
|
||||||
async headers() {
|
async headers() {
|
||||||
|
|||||||
Generated
-138
@@ -28,8 +28,6 @@
|
|||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-toast": "^1.2.15",
|
"@radix-ui/react-toast": "^1.2.15",
|
||||||
"@supabase/ssr": "^0.10.3",
|
|
||||||
"@supabase/supabase-js": "^2.106.1",
|
|
||||||
"browser-image-compression": "^2.0.2",
|
"browser-image-compression": "^2.0.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -43,7 +41,6 @@
|
|||||||
"react-hook-form": "^7.76.0",
|
"react-hook-form": "^7.76.0",
|
||||||
"react-konva": "^18.2.16",
|
"react-konva": "^18.2.16",
|
||||||
"react-rnd": "^10.5.3",
|
"react-rnd": "^10.5.3",
|
||||||
"stripe": "^22.1.1",
|
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"use-image": "^1.1.4",
|
"use-image": "^1.1.4",
|
||||||
"use-undoable": "^5.0.0",
|
"use-undoable": "^5.0.0",
|
||||||
@@ -3455,102 +3452,6 @@
|
|||||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/auth-js": {
|
|
||||||
"version": "2.106.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.106.1.tgz",
|
|
||||||
"integrity": "sha512-7eyheXfAGwkB9bZewJPs+N3UYt6kra2JG6mIxNEgbkvcO15PLD1e75PTIUEYYl3zrifm3GrpShVl7QZxKrXO/w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "2.8.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@supabase/functions-js": {
|
|
||||||
"version": "2.106.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.106.1.tgz",
|
|
||||||
"integrity": "sha512-XbOPnR2mW7jp/EcW447xmGwCa+/Wc00Hkw8t4tUIJjRsHQ4xAESsLKcyLRhRJjJoUnJVXUlC+w0wUxUCM7CG2A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "2.8.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@supabase/phoenix": {
|
|
||||||
"version": "0.4.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz",
|
|
||||||
"integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@supabase/postgrest-js": {
|
|
||||||
"version": "2.106.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.106.1.tgz",
|
|
||||||
"integrity": "sha512-Qbn6d2lqiqeaBX1Uko0e/hL90dtQGRN6CG2wMVQtJpRFstlVW45qmUTyTOsiB8dYUWu1fWYo4YzJuDbokGv3tQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "2.8.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@supabase/realtime-js": {
|
|
||||||
"version": "2.106.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.106.1.tgz",
|
|
||||||
"integrity": "sha512-eQCYri5E8KsjpDgC7g28cOOS2britjUWdNSJluFMainqrMRepzjOnaxqXc3RoAz7H0dxmBrfLUNF6NGP8C+YaA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@supabase/phoenix": "^0.4.2",
|
|
||||||
"tslib": "2.8.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@supabase/ssr": {
|
|
||||||
"version": "0.10.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.10.3.tgz",
|
|
||||||
"integrity": "sha512-ux2CJgX89h0Fz2lY7ZNafNG2SkXpyRc5dz77K9eKeBLPdtywQixKwIuetDeIViAJBp/buOUVmgj8PVesOklNpw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"cookie": "^1.0.2"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@supabase/supabase-js": "^2.105.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@supabase/storage-js": {
|
|
||||||
"version": "2.106.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.106.1.tgz",
|
|
||||||
"integrity": "sha512-HWcLIhqinhWKpOQ3WzglR2unjW0eh9J7yOu3IZrZNIEkraK4La/HDvTqndljGsNw0itPtyHhuKBxRoPG1VUARw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"iceberg-js": "^0.8.1",
|
|
||||||
"tslib": "2.8.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@supabase/supabase-js": {
|
|
||||||
"version": "2.106.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.106.1.tgz",
|
|
||||||
"integrity": "sha512-gP4HurGkGu7Z3xoOCjtAI17BKKp7jpsmwY0Ssbsks9XQRzJ7ZhK7LxfLdBSYgUdgZCQgjRK+Mr7+cl4Gxrk0Rw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@supabase/auth-js": "2.106.1",
|
|
||||||
"@supabase/functions-js": "2.106.1",
|
|
||||||
"@supabase/postgrest-js": "2.106.1",
|
|
||||||
"@supabase/realtime-js": "2.106.1",
|
|
||||||
"@supabase/storage-js": "2.106.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@swc/core-darwin-arm64": {
|
"node_modules/@swc/core-darwin-arm64": {
|
||||||
"version": "1.15.40",
|
"version": "1.15.40",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.40.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.40.tgz",
|
||||||
@@ -5336,19 +5237,6 @@
|
|||||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/cookie": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/core-util-is": {
|
"node_modules/core-util-is": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
@@ -7176,15 +7064,6 @@
|
|||||||
"ms": "^2.0.0"
|
"ms": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/iceberg-js": {
|
|
||||||
"version": "0.8.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
|
|
||||||
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/icu-minify": {
|
"node_modules/icu-minify": {
|
||||||
"version": "4.12.0",
|
"version": "4.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.12.0.tgz",
|
||||||
@@ -10527,23 +10406,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/stripe": {
|
|
||||||
"version": "22.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/stripe/-/stripe-22.1.1.tgz",
|
|
||||||
"integrity": "sha512-cmodIYP27tBkJ8G7DuGgWw0PFuemlFZbuF3Wwr1TrjFjUa3T7NIgCe6TVwX8BO2ynu+xtTuDGfHafNDCPt9lXA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/node": ">=18"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/node": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/strnum": {
|
"node_modules/strnum": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz",
|
||||||
|
|||||||
+1
-7
@@ -6,8 +6,7 @@
|
|||||||
"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/core": "^6.3.1",
|
||||||
@@ -19,7 +18,6 @@
|
|||||||
"@fontsource-variable/plus-jakarta-sans": "^5.2.8",
|
"@fontsource-variable/plus-jakarta-sans": "^5.2.8",
|
||||||
"@fontsource/vazirmatn": "^5.2.8",
|
"@fontsource/vazirmatn": "^5.2.8",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@nexrender/core": "^1.46.0",
|
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@@ -30,8 +28,6 @@
|
|||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-toast": "^1.2.15",
|
"@radix-ui/react-toast": "^1.2.15",
|
||||||
"@supabase/ssr": "^0.10.3",
|
|
||||||
"@supabase/supabase-js": "^2.106.1",
|
|
||||||
"browser-image-compression": "^2.0.2",
|
"browser-image-compression": "^2.0.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -45,7 +41,6 @@
|
|||||||
"react-hook-form": "^7.76.0",
|
"react-hook-form": "^7.76.0",
|
||||||
"react-konva": "^18.2.16",
|
"react-konva": "^18.2.16",
|
||||||
"react-rnd": "^10.5.3",
|
"react-rnd": "^10.5.3",
|
||||||
"stripe": "^22.1.1",
|
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"use-image": "^1.1.4",
|
"use-image": "^1.1.4",
|
||||||
"use-undoable": "^5.0.0",
|
"use-undoable": "^5.0.0",
|
||||||
@@ -61,7 +56,6 @@
|
|||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tsx": "^4.19.4",
|
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-6
@@ -1,7 +1,7 @@
|
|||||||
<svg width="32" height="32" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="32" height="32" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<rect width="40" height="40" rx="9" fill="#2563EB"/>
|
<rect width="48" height="48" rx="12" fill="#2563EB"/>
|
||||||
<path d="M12 12.5L12 27.5L24.5 20L12 12.5Z" fill="white"/>
|
<rect x="16" y="13" width="3.6" height="22" rx="1.8" fill="white"/>
|
||||||
<rect x="27" y="13" width="7" height="2.5" rx="1.25" fill="white" fill-opacity="0.9"/>
|
<rect x="16" y="13" width="16" height="3.6" rx="1.8" fill="white"/>
|
||||||
<rect x="27" y="18.75" width="5.5" height="2.5" rx="1.25" fill="white" fill-opacity="0.75"/>
|
<rect x="16" y="22.2" width="11" height="3.6" rx="1.8" fill="white" fill-opacity="0.75"/>
|
||||||
<rect x="27" y="24.5" width="4" height="2.5" rx="1.25" fill="white" fill-opacity="0.6"/>
|
<path d="M30 29L35.5 32L30 35Z" fill="white" fill-opacity="0.9"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 494 B After Width: | Height: | Size: 459 B |
@@ -0,0 +1,47 @@
|
|||||||
|
// One-off: merge workflow localization output into messages/{fa,en}.json under "auto",
|
||||||
|
// then report any auto.* namespaces referenced in src/ but missing from messages (orphans
|
||||||
|
// from failed batches that edited files without returning keys).
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const cp = require("child_process");
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, "..");
|
||||||
|
const outFile = process.argv[2];
|
||||||
|
if (!outFile) { console.error("usage: node merge-i18n.js <workflow-output-file>"); process.exit(1); }
|
||||||
|
|
||||||
|
// 1. Extract the result JSON from the workflow output file (whole file is valid JSON).
|
||||||
|
const raw = fs.readFileSync(outFile, "utf8");
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
const result = parsed.result || parsed;
|
||||||
|
const localized = result.localized || [];
|
||||||
|
console.log(`workflow result: localized=${localized.length} skipped=${(result.skipped||[]).length}`);
|
||||||
|
|
||||||
|
// 2. Merge into messages, preserving existing keys; create "auto" namespace.
|
||||||
|
for (const locale of ["fa", "en"]) {
|
||||||
|
const file = path.join(ROOT, "messages", `${locale}.json`);
|
||||||
|
const msg = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||||
|
msg.auto = msg.auto || {};
|
||||||
|
let added = 0;
|
||||||
|
for (const item of localized) {
|
||||||
|
if (!item.pathKey) continue;
|
||||||
|
const payload = locale === "fa" ? item.fa : item.en;
|
||||||
|
if (payload && typeof payload === "object") { msg.auto[item.pathKey] = payload; added++; }
|
||||||
|
}
|
||||||
|
fs.writeFileSync(file, JSON.stringify(msg, null, 2) + "\n");
|
||||||
|
console.log(`${locale}.json: merged ${added} namespaces (auto.* total=${Object.keys(msg.auto).length})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Find auto.* namespaces referenced in src but missing from merged en.json → orphans.
|
||||||
|
const en = JSON.parse(fs.readFileSync(path.join(ROOT, "messages", "en.json"), "utf8"));
|
||||||
|
const present = new Set(Object.keys(en.auto || {}));
|
||||||
|
const grep = cp.spawnSync(
|
||||||
|
"grep",
|
||||||
|
["-rhoE", "(useTranslations|getTranslations)\\(\"auto\\.[a-zA-Z0-9]+\"", path.join(ROOT, "src")],
|
||||||
|
{ encoding: "utf8" }
|
||||||
|
);
|
||||||
|
const referenced = new Set();
|
||||||
|
for (const m of (grep.stdout || "").matchAll(/auto\.([a-zA-Z0-9]+)/g)) referenced.add(m[1]);
|
||||||
|
const orphans = [...referenced].filter((ns) => !present.has(ns));
|
||||||
|
console.log(`\nreferenced auto.* namespaces: ${referenced.size}`);
|
||||||
|
console.log(`ORPHANS (referenced but missing keys): ${orphans.length}`);
|
||||||
|
orphans.forEach((o) => console.log(" - auto." + o));
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
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
@@ -1,13 +0,0 @@
|
|||||||
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 }>;
|
|
||||||
}
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,177 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using FlatRender.ContentSvc.Domain.Entities;
|
||||||
|
using FlatRender.ContentSvc.Infrastructure.Data;
|
||||||
|
using FlatRender.ContentSvc.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FlatRender.ContentSvc.Application.Services;
|
||||||
|
|
||||||
|
/// <summary>Thrown for expected/config errors (missing key, provider error) → mapped to 400 by the controller.</summary>
|
||||||
|
public class AiConfigException(string message) : Exception(message);
|
||||||
|
|
||||||
|
public class AiContentService(ContentDbContext db, IHttpClientFactory httpFactory)
|
||||||
|
{
|
||||||
|
public static readonly Guid DefaultTenant = Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||||
|
|
||||||
|
// ── Settings ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<AiSettings> GetRawAsync(Guid tenantId)
|
||||||
|
{
|
||||||
|
return await db.AiSettings.FirstOrDefaultAsync(x => x.TenantId == tenantId)
|
||||||
|
?? new AiSettings { TenantId = tenantId };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AiSettingsResponse> GetSettingsAsync(Guid tenantId)
|
||||||
|
{
|
||||||
|
var s = await GetRawAsync(tenantId);
|
||||||
|
var key = s.ApiKey;
|
||||||
|
var has = !string.IsNullOrWhiteSpace(key);
|
||||||
|
var masked = has ? $"••••••••{key![Math.Max(0, key.Length - 4)..]}" : null;
|
||||||
|
return new AiSettingsResponse(s.Provider, s.BaseUrl, s.Model, s.Enabled, has, masked,
|
||||||
|
s.UpdatedAt == default ? null : s.UpdatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AiSettingsResponse> UpdateSettingsAsync(Guid tenantId, UpdateAiSettingsRequest req)
|
||||||
|
{
|
||||||
|
var s = await db.AiSettings.FirstOrDefaultAsync(x => x.TenantId == tenantId);
|
||||||
|
var isNew = s is null;
|
||||||
|
s ??= new AiSettings { TenantId = tenantId };
|
||||||
|
|
||||||
|
if (req.Provider is { } p) s.Provider = p;
|
||||||
|
if (req.BaseUrl is { } b && !string.IsNullOrWhiteSpace(b)) s.BaseUrl = b.TrimEnd('/');
|
||||||
|
if (req.Model is { } m && !string.IsNullOrWhiteSpace(m)) s.Model = m;
|
||||||
|
if (req.Enabled is { } e) s.Enabled = e;
|
||||||
|
// ApiKey: null = leave unchanged; non-null (incl. "") = set/clear.
|
||||||
|
if (req.ApiKey is not null) s.ApiKey = string.IsNullOrWhiteSpace(req.ApiKey) ? null : req.ApiKey.Trim();
|
||||||
|
s.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
if (isNew) db.AiSettings.Add(s);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return await GetSettingsAsync(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generation ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<SeoPostResponse> GenerateSeoPostAsync(Guid tenantId, GenerateSeoPostRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var s = await GetRawAsync(tenantId);
|
||||||
|
if (!s.Enabled) throw new AiConfigException("AI generation is disabled. Enable it in AI settings.");
|
||||||
|
if (string.IsNullOrWhiteSpace(s.ApiKey)) throw new AiConfigException("No OpenAI API key configured. Add one in AI settings.");
|
||||||
|
if (string.IsNullOrWhiteSpace(req.Description)) throw new AiConfigException("A description is required.");
|
||||||
|
|
||||||
|
var locale = (req.Locale ?? "fa").ToLowerInvariant();
|
||||||
|
var langName = locale == "en" ? "English" : "Persian (Farsi)";
|
||||||
|
|
||||||
|
var system =
|
||||||
|
"You are a senior SEO content strategist and copywriter. Given a product/page description and metadata, " +
|
||||||
|
"write an original, engaging, well-structured, SEO-optimized article. " +
|
||||||
|
"Return ONLY a single valid JSON object (no markdown, no code fences) with EXACTLY these keys: " +
|
||||||
|
"title, slug, meta_title, meta_description, keywords, short_description, content_html. " +
|
||||||
|
"Rules: " +
|
||||||
|
$"write all human-readable text in {langName}; " +
|
||||||
|
"slug must be a short lowercase ASCII (a-z, 0-9, hyphens) URL slug derived from the topic, even when the article is in Persian; " +
|
||||||
|
"meta_title <= 60 characters; meta_description <= 160 characters and compelling; " +
|
||||||
|
"keywords = array of 5-8 relevant search keywords; " +
|
||||||
|
"short_description = 1-2 sentence summary; " +
|
||||||
|
"content_html = semantic HTML using <h2>, <h3>, <p>, <ul><li>, <strong> (no <html>/<body>/<h1>), 500-900 words, " +
|
||||||
|
"naturally incorporating the keywords, with a short intro, scannable sections, and a closing call to action.";
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("Write an SEO article for the following:");
|
||||||
|
if (!string.IsNullOrWhiteSpace(req.Title)) sb.AppendLine($"Working title: {req.Title}");
|
||||||
|
if (!string.IsNullOrWhiteSpace(req.Type)) sb.AppendLine($"Content type: {req.Type}");
|
||||||
|
if (req.Tags is { Length: > 0 }) sb.AppendLine($"Tags: {string.Join(", ", req.Tags)}");
|
||||||
|
if (!string.IsNullOrWhiteSpace(req.Keyword)) sb.AppendLine($"Primary target keyword: {req.Keyword}");
|
||||||
|
if (!string.IsNullOrWhiteSpace(req.Audience)) sb.AppendLine($"Target audience: {req.Audience}");
|
||||||
|
sb.AppendLine($"Description / brief:\n{req.Description}");
|
||||||
|
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
model = s.Model,
|
||||||
|
messages = new object[]
|
||||||
|
{
|
||||||
|
new { role = "system", content = system },
|
||||||
|
new { role = "user", content = sb.ToString() },
|
||||||
|
},
|
||||||
|
temperature = 0.7,
|
||||||
|
response_format = new { type = "json_object" },
|
||||||
|
};
|
||||||
|
|
||||||
|
var http = httpFactory.CreateClient("openai");
|
||||||
|
http.Timeout = TimeSpan.FromSeconds(90);
|
||||||
|
using var msg = new HttpRequestMessage(HttpMethod.Post, $"{s.BaseUrl.TrimEnd('/')}/chat/completions")
|
||||||
|
{
|
||||||
|
Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"),
|
||||||
|
};
|
||||||
|
msg.Headers.Authorization = new AuthenticationHeaderValue("Bearer", s.ApiKey);
|
||||||
|
|
||||||
|
HttpResponseMessage resp;
|
||||||
|
try { resp = await http.SendAsync(msg, ct); }
|
||||||
|
catch (Exception ex) { throw new AiConfigException($"Could not reach the AI provider: {ex.Message}"); }
|
||||||
|
|
||||||
|
var body = await resp.Content.ReadAsStringAsync(ct);
|
||||||
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
throw new AiConfigException($"AI provider returned {(int)resp.StatusCode}: {Truncate(body, 300)}");
|
||||||
|
|
||||||
|
string contentJson;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
contentJson = doc.RootElement.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString() ?? "";
|
||||||
|
}
|
||||||
|
catch (Exception ex) { throw new AiConfigException($"Unexpected AI response shape: {ex.Message}"); }
|
||||||
|
|
||||||
|
return ParsePost(contentJson, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SeoPostResponse ParsePost(string contentJson, GenerateSeoPostRequest req)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(contentJson);
|
||||||
|
var r = doc.RootElement;
|
||||||
|
string Str(string k) => r.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString()! : "";
|
||||||
|
string[] Keywords()
|
||||||
|
{
|
||||||
|
if (r.TryGetProperty("keywords", out var v))
|
||||||
|
{
|
||||||
|
if (v.ValueKind == JsonValueKind.Array)
|
||||||
|
return v.EnumerateArray().Where(e => e.ValueKind == JsonValueKind.String).Select(e => e.GetString()!).ToArray();
|
||||||
|
if (v.ValueKind == JsonValueKind.String)
|
||||||
|
return v.GetString()!.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var title = Str("title");
|
||||||
|
if (string.IsNullOrWhiteSpace(title)) title = req.Title ?? "Untitled";
|
||||||
|
var slug = Slugify(Str("slug"));
|
||||||
|
if (string.IsNullOrWhiteSpace(slug)) slug = Slugify(req.Keyword ?? title);
|
||||||
|
if (string.IsNullOrWhiteSpace(slug)) slug = "post";
|
||||||
|
|
||||||
|
return new SeoPostResponse(
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
Str("meta_title") is { Length: > 0 } mt ? mt : title,
|
||||||
|
Str("meta_description"),
|
||||||
|
Keywords(),
|
||||||
|
Str("short_description"),
|
||||||
|
Str("content_html")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch (Exception ex) { throw new AiConfigException($"Could not parse AI content as JSON: {ex.Message}"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Slugify(string s)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(s)) return "";
|
||||||
|
s = s.Trim().ToLowerInvariant();
|
||||||
|
s = Regex.Replace(s, @"[^a-z0-9]+", "-").Trim('-');
|
||||||
|
return s.Length > 80 ? s[..80].Trim('-') : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Truncate(string s, int n) => s.Length <= n ? s : s[..n] + "…";
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using FlatRender.ContentSvc.Application.Services;
|
||||||
|
using FlatRender.ContentSvc.Models;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace FlatRender.ContentSvc.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("v1/ai")]
|
||||||
|
[Authorize]
|
||||||
|
public class AiController(AiContentService svc) : ControllerBase
|
||||||
|
{
|
||||||
|
private Guid TenantId =>
|
||||||
|
Guid.TryParse(User.FindFirstValue("tenant_id"), out var t) ? t : AiContentService.DefaultTenant;
|
||||||
|
|
||||||
|
private bool IsAdmin =>
|
||||||
|
string.Equals(User.FindFirstValue("is_admin"), "true", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(User.FindFirstValue("is_tenant_admin"), "true", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
[HttpGet("settings")]
|
||||||
|
public async Task<IActionResult> GetSettings()
|
||||||
|
{
|
||||||
|
if (!IsAdmin) return Forbidden();
|
||||||
|
return Ok(await svc.GetSettingsAsync(TenantId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("settings")]
|
||||||
|
public async Task<IActionResult> UpdateSettings([FromBody] UpdateAiSettingsRequest req)
|
||||||
|
{
|
||||||
|
if (!IsAdmin) return Forbidden();
|
||||||
|
return Ok(await svc.UpdateSettingsAsync(TenantId, req));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("seo-post")]
|
||||||
|
public async Task<IActionResult> GenerateSeoPost([FromBody] GenerateSeoPostRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!IsAdmin) return Forbidden();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Ok(await svc.GenerateSeoPostAsync(TenantId, req, ct));
|
||||||
|
}
|
||||||
|
catch (AiConfigException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = new { code = "ai_error", message = ex.Message } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IActionResult Forbidden() =>
|
||||||
|
StatusCode(403, new { error = new { code = "forbidden", message = "Admin access required." } });
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace FlatRender.ContentSvc.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>Per-tenant OpenAI (or OpenAI-compatible) configuration for the AI content generator.</summary>
|
||||||
|
public class AiSettings
|
||||||
|
{
|
||||||
|
public Guid TenantId { get; set; }
|
||||||
|
public string Provider { get; set; } = "openai";
|
||||||
|
public string? ApiKey { get; set; }
|
||||||
|
public string BaseUrl { get; set; } = "https://api.openai.com/v1";
|
||||||
|
public string Model { get; set; } = "gpt-4o-mini";
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -58,6 +58,9 @@ public class ContentDbContext(DbContextOptions<ContentDbContext> options) : DbCo
|
|||||||
public DbSet<FavoriteFolder> FavoriteFolders => Set<FavoriteFolder>();
|
public DbSet<FavoriteFolder> FavoriteFolders => Set<FavoriteFolder>();
|
||||||
public DbSet<FavoriteContainer> FavoriteContainers => Set<FavoriteContainer>();
|
public DbSet<FavoriteContainer> FavoriteContainers => Set<FavoriteContainer>();
|
||||||
|
|
||||||
|
// AI
|
||||||
|
public DbSet<AiSettings> AiSettings => Set<AiSettings>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder mb)
|
protected override void OnModelCreating(ModelBuilder mb)
|
||||||
{
|
{
|
||||||
mb.HasDefaultSchema("content");
|
mb.HasDefaultSchema("content");
|
||||||
@@ -70,6 +73,13 @@ public class ContentDbContext(DbContextOptions<ContentDbContext> options) : DbCo
|
|||||||
ConfigureScenes(mb);
|
ConfigureScenes(mb);
|
||||||
ConfigureCharacters(mb);
|
ConfigureCharacters(mb);
|
||||||
ConfigureCms(mb);
|
ConfigureCms(mb);
|
||||||
|
|
||||||
|
// AI settings — snake_case convention maps columns (tenant_id, api_key, …).
|
||||||
|
mb.Entity<AiSettings>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("ai_settings");
|
||||||
|
e.HasKey(x => x.TenantId);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ConfigureTaxonomy(ModelBuilder mb)
|
private static void ConfigureTaxonomy(ModelBuilder mb)
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
namespace FlatRender.ContentSvc.Models;
|
||||||
|
|
||||||
|
// ── AI settings ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Settings returned to the admin UI. The API key is never returned in full.</summary>
|
||||||
|
public record AiSettingsResponse(
|
||||||
|
string Provider,
|
||||||
|
string BaseUrl,
|
||||||
|
string Model,
|
||||||
|
bool Enabled,
|
||||||
|
bool HasApiKey,
|
||||||
|
string? ApiKeyMasked,
|
||||||
|
DateTime? UpdatedAt
|
||||||
|
);
|
||||||
|
|
||||||
|
public record UpdateAiSettingsRequest(
|
||||||
|
string? Provider,
|
||||||
|
string? ApiKey, // null = leave unchanged; "" = clear
|
||||||
|
string? BaseUrl,
|
||||||
|
string? Model,
|
||||||
|
bool? Enabled
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── SEO post generation ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public record GenerateSeoPostRequest(
|
||||||
|
string Description,
|
||||||
|
string? Title,
|
||||||
|
string? Type, // e.g. "video template", "image template", "product"
|
||||||
|
string[]? Tags,
|
||||||
|
string? Locale, // "fa" (default) or "en"
|
||||||
|
string? Audience, // optional target-audience hint
|
||||||
|
string? Keyword // optional primary keyword to target
|
||||||
|
);
|
||||||
|
|
||||||
|
public record SeoPostResponse(
|
||||||
|
string Title,
|
||||||
|
string Slug,
|
||||||
|
string MetaTitle,
|
||||||
|
string MetaDescription,
|
||||||
|
string[] Keywords,
|
||||||
|
string ShortDescription,
|
||||||
|
string ContentHtml
|
||||||
|
);
|
||||||
@@ -50,7 +50,9 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|||||||
ValidIssuer = builder.Configuration["Jwt:Issuer"],
|
ValidIssuer = builder.Configuration["Jwt:Issuer"],
|
||||||
ValidAudience = builder.Configuration["Jwt:Audience"],
|
ValidAudience = builder.Configuration["Jwt:Audience"],
|
||||||
IssuerSigningKey = new SymmetricSecurityKey(
|
IssuerSigningKey = new SymmetricSecurityKey(
|
||||||
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!))
|
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!)),
|
||||||
|
// The token's "role" claim is auto-mapped to ClaimTypes.Role by the default
|
||||||
|
// inbound claim mapping, which is what [Authorize(Roles = "Admin")] reads.
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,6 +63,10 @@ builder.Services.AddAuthorization();
|
|||||||
builder.Services.AddScoped<TaxonomyService>();
|
builder.Services.AddScoped<TaxonomyService>();
|
||||||
builder.Services.AddScoped<TemplateService>();
|
builder.Services.AddScoped<TemplateService>();
|
||||||
builder.Services.AddScoped<CmsService>();
|
builder.Services.AddScoped<CmsService>();
|
||||||
|
builder.Services.AddScoped<AiContentService>();
|
||||||
|
|
||||||
|
// HTTP client for the OpenAI-compatible AI provider (base URL is per-tenant config).
|
||||||
|
builder.Services.AddHttpClient("openai");
|
||||||
|
|
||||||
// ── HTTP ──────────────────────────────────────────────────────────────────────
|
// ── HTTP ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ func main() {
|
|||||||
v1.Any("/settings/*path", apiRL, optionalAuth, content.Handler())
|
v1.Any("/settings/*path", apiRL, optionalAuth, content.Handler())
|
||||||
v1.Any("/comments/*path", apiRL, auth, content.Handler())
|
v1.Any("/comments/*path", apiRL, auth, content.Handler())
|
||||||
v1.Any("/favorites/*path", apiRL, auth, content.Handler())
|
v1.Any("/favorites/*path", apiRL, auth, content.Handler())
|
||||||
|
v1.Any("/ai/*path", apiRL, auth, content.Handler())
|
||||||
|
|
||||||
// ── File Service ─────────────────────────────────────────────────────────
|
// ── File Service ─────────────────────────────────────────────────────────
|
||||||
v1.Any("/files/*path", apiRL, auth, file.Handler())
|
v1.Any("/files/*path", apiRL, auth, file.Handler())
|
||||||
|
|||||||
+3
@@ -9,4 +9,7 @@ public interface IPlanService
|
|||||||
Task<PlanResponse> GetByIdAsync(Guid planId);
|
Task<PlanResponse> GetByIdAsync(Guid planId);
|
||||||
Task<UserPlanResponse?> GetCurrentPlanAsync(Guid userId);
|
Task<UserPlanResponse?> GetCurrentPlanAsync(Guid userId);
|
||||||
Task<PurchasePlanResponse> PurchasePlanAsync(Guid userId, Guid tenantId, PurchasePlanRequest request);
|
Task<PurchasePlanResponse> PurchasePlanAsync(Guid userId, Guid tenantId, PurchasePlanRequest request);
|
||||||
|
/// <summary>Cancel the current active plan. The subscription is marked cancelled
|
||||||
|
/// and will not auto-renew. Access continues until the expiry date.</summary>
|
||||||
|
Task CancelPlanAsync(Guid userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,6 +161,19 @@ public class PlanService(IdentityDbContext db) : IPlanService
|
|||||||
await Task.CompletedTask; // placeholder for future async work
|
await Task.CompletedTask; // placeholder for future async work
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task CancelPlanAsync(Guid userId)
|
||||||
|
{
|
||||||
|
var userPlan = await db.UserPlans
|
||||||
|
.Where(up => up.UserId == userId && up.CancelledAt == null && up.ExpiresAt > DateTime.UtcNow)
|
||||||
|
.OrderByDescending(up => up.StartsAt)
|
||||||
|
.FirstOrDefaultAsync()
|
||||||
|
?? throw new KeyNotFoundException("No active plan to cancel");
|
||||||
|
|
||||||
|
userPlan.CancelledAt = DateTime.UtcNow;
|
||||||
|
userPlan.AutoRenew = false;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
private static PlanResponse MapPlanResponse(Plan p) => new(
|
private static PlanResponse MapPlanResponse(Plan p) => new(
|
||||||
p.Id, p.Code, p.Name, p.Description,
|
p.Id, p.Code, p.Name, p.Description,
|
||||||
p.PriceMinor, p.BeforePriceMinor, p.Currency, p.BillingPeriod.ToString(),
|
p.PriceMinor, p.BeforePriceMinor, p.Currency, p.BillingPeriod.ToString(),
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ public class TokenService(IConfiguration config) : ITokenService
|
|||||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret));
|
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret));
|
||||||
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||||
|
|
||||||
|
// Role claim drives [Authorize(Roles = "...")] in the other services.
|
||||||
|
var role = user.IsAdmin ? "Admin" : user.IsTenantAdmin ? "TenantAdmin" : "User";
|
||||||
|
|
||||||
var claims = new List<Claim>
|
var claims = new List<Claim>
|
||||||
{
|
{
|
||||||
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
||||||
@@ -29,6 +32,7 @@ public class TokenService(IConfiguration config) : ITokenService
|
|||||||
new("tenant_slug", tenant.Slug),
|
new("tenant_slug", tenant.Slug),
|
||||||
new("is_admin", user.IsAdmin.ToString().ToLower()),
|
new("is_admin", user.IsAdmin.ToString().ToLower()),
|
||||||
new("is_tenant_admin", user.IsTenantAdmin.ToString().ToLower()),
|
new("is_tenant_admin", user.IsTenantAdmin.ToString().ToLower()),
|
||||||
|
new("role", role),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(user.Email))
|
if (!string.IsNullOrEmpty(user.Email))
|
||||||
|
|||||||
@@ -39,6 +39,26 @@ public class PlansController(IPlanService planService) : ControllerBase
|
|||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cancel the current active subscription. The plan stays active until its
|
||||||
|
/// expiry date but will not auto-renew. Returns 404 when no active plan exists.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("users/me/plan/cancel")]
|
||||||
|
[ProducesResponseType(204)]
|
||||||
|
[ProducesResponseType(404)]
|
||||||
|
public async Task<IActionResult> Cancel()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await planService.CancelPlanAsync(GetUserId());
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException ex)
|
||||||
|
{
|
||||||
|
return NotFound(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private Guid GetUserId() => Guid.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value
|
private Guid GetUserId() => Guid.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value
|
||||||
?? User.FindFirst("sub")?.Value ?? throw new UnauthorizedAccessException());
|
?? User.FindFirst("sub")?.Value ?? throw new UnauthorizedAccessException());
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Build stage — cross-compile for Windows amd64
|
||||||
|
FROM golang:1.25-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod ./
|
||||||
|
# No external dependencies yet — no go.sum needed
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Produce a static Windows binary
|
||||||
|
RUN GOOS=windows GOARCH=amd64 CGO_ENABLED=0 \
|
||||||
|
go build -mod=mod -trimpath -ldflags="-s -w" \
|
||||||
|
-o /out/flatrender-node-agent.exe \
|
||||||
|
./cmd/agent
|
||||||
|
|
||||||
|
# ── Output stage ───────────────────────────────────────────────────────────────
|
||||||
|
# For dev/CI use only — the production binary is copied to Windows render nodes.
|
||||||
|
FROM scratch
|
||||||
|
COPY --from=builder /out/flatrender-node-agent.exe /flatrender-node-agent.exe
|
||||||
|
ENTRYPOINT ["/flatrender-node-agent.exe"]
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
// FlatRender V2 Node Agent
|
||||||
|
//
|
||||||
|
// Runs on every Windows render node. Registers itself with the V2 render
|
||||||
|
// orchestrator, sends heartbeats, claims render jobs, executes them via
|
||||||
|
// After Effects (or mock), and reports progress / completion back.
|
||||||
|
//
|
||||||
|
// Required environment variables:
|
||||||
|
// NODE_ID — UUID of this node (pre-created in render.render_nodes)
|
||||||
|
//
|
||||||
|
// Optional environment variables (all have sensible defaults):
|
||||||
|
// ORCHESTRATOR_URL — gateway base URL (default: http://localhost:8088)
|
||||||
|
// NODE_HMAC_SECRET — shared secret (default: node-secret-change-me)
|
||||||
|
// NODE_REGION — region label, e.g. "iran-tehran-1"
|
||||||
|
// AE_PATH — path to aerender.exe; empty = mock render
|
||||||
|
// WORK_DIR — scratch directory (default: system temp)
|
||||||
|
// AGENT_VERSION — semver string (default: 0.1.0)
|
||||||
|
// AE_VERSION — AE version string reported to orchestrator (default: 2024)
|
||||||
|
// HEARTBEAT_INTERVAL_SEC — seconds between heartbeats (default: 5)
|
||||||
|
// POLL_INTERVAL_SEC — seconds between job-claim attempts when idle (default: 3)
|
||||||
|
// LISTEN_PORT — port for the health endpoint (default: 7777)
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/flatrender/node-agent/internal/client"
|
||||||
|
"github.com/flatrender/node-agent/internal/config"
|
||||||
|
"github.com/flatrender/node-agent/internal/runner"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Agent state ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Agent struct {
|
||||||
|
cfg *config.Config
|
||||||
|
orch *client.Client
|
||||||
|
mu sync.Mutex
|
||||||
|
currentJob *client.ClaimedJob
|
||||||
|
status string // "Ready" | "Busy"
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAgent(cfg *config.Config) *Agent {
|
||||||
|
return &Agent{
|
||||||
|
cfg: cfg,
|
||||||
|
orch: client.New(cfg.OrchestratorURL, cfg.NodeHMACSecret),
|
||||||
|
status: "Ready",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) setJob(job *client.ClaimedJob) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
a.currentJob = job
|
||||||
|
if job != nil {
|
||||||
|
a.status = "Busy"
|
||||||
|
} else {
|
||||||
|
a.status = "Ready"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) getStatus() (string, *string) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
if a.currentJob != nil {
|
||||||
|
jobID := a.currentJob.JobID
|
||||||
|
return a.status, &jobID
|
||||||
|
}
|
||||||
|
return a.status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds)
|
||||||
|
log.Printf("FlatRender Node Agent v%s starting (OS: %s, Arch: %s)",
|
||||||
|
"0.1.0", runtime.GOOS, runtime.GOARCH)
|
||||||
|
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("config: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("Node ID: %s | Region: %q | Orchestrator: %s",
|
||||||
|
cfg.NodeID, cfg.Region, cfg.OrchestratorURL)
|
||||||
|
if cfg.AEPath == "" {
|
||||||
|
log.Printf("AE_PATH not set — using mock renderer (development mode)")
|
||||||
|
} else {
|
||||||
|
log.Printf("AE binary: %s", cfg.AEPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
agent := newAgent(cfg)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Graceful shutdown on SIGTERM / SIGINT
|
||||||
|
sigs := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigs, syscall.SIGTERM, syscall.SIGINT)
|
||||||
|
go func() {
|
||||||
|
s := <-sigs
|
||||||
|
log.Printf("received %s, shutting down…", s)
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Register as online
|
||||||
|
if err := agent.registerOnline(ctx); err != nil {
|
||||||
|
log.Fatalf("failed to register with orchestrator: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start health endpoint
|
||||||
|
go agent.serveHealth(ctx)
|
||||||
|
|
||||||
|
// Main loops
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
go func() { defer wg.Done(); agent.heartbeatLoop(ctx) }()
|
||||||
|
go func() { defer wg.Done(); agent.pollLoop(ctx) }()
|
||||||
|
wg.Wait()
|
||||||
|
log.Printf("shutdown complete")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Registration ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (a *Agent) registerOnline(ctx context.Context) error {
|
||||||
|
req := client.OnlineRequest{
|
||||||
|
NodeAgentVersion: a.cfg.AgentVersion,
|
||||||
|
CurrentAEVersion: a.cfg.AEVersion,
|
||||||
|
AvailableAEVersions: []string{a.cfg.AEVersion},
|
||||||
|
CachedTemplateMD5s: []string{},
|
||||||
|
}
|
||||||
|
if err := a.orch.Online(ctx, a.cfg.NodeID, req); err != nil {
|
||||||
|
return fmt.Errorf("online: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("registered as online with orchestrator")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Heartbeat loop ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (a *Agent) heartbeatLoop(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(time.Duration(a.cfg.HeartbeatIntervalSec) * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
a.sendHeartbeat(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) sendHeartbeat(ctx context.Context) {
|
||||||
|
status, jobID := a.getStatus()
|
||||||
|
req := client.HeartbeatRequest{
|
||||||
|
NodeID: a.cfg.NodeID,
|
||||||
|
Status: status,
|
||||||
|
CurrentJobID: jobID,
|
||||||
|
}
|
||||||
|
hbCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resp, err := a.orch.Heartbeat(hbCtx, a.cfg.NodeID, req)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("heartbeat error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(resp.PendingCommands) > 0 {
|
||||||
|
log.Printf("orchestrator commands: %v", resp.PendingCommands)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Job poll loop ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (a *Agent) pollLoop(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(time.Duration(a.cfg.PollIntervalSec) * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
status, _ := a.getStatus()
|
||||||
|
if status == "Busy" {
|
||||||
|
continue // already rendering
|
||||||
|
}
|
||||||
|
a.tryClaimAndRun(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) tryClaimAndRun(ctx context.Context) {
|
||||||
|
claimCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
job, err := a.orch.ClaimJob(claimCtx, a.cfg.NodeID, a.cfg.Region)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("claim error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if job == nil {
|
||||||
|
return // queue empty
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("claimed job %s (project %s, %s %s %dfps)",
|
||||||
|
job.JobID, job.SavedProjectID, job.Quality, job.Resolution, job.FrameRate)
|
||||||
|
|
||||||
|
a.setJob(job)
|
||||||
|
go func() {
|
||||||
|
defer a.setJob(nil)
|
||||||
|
a.runJob(ctx, job)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render execution ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (a *Agent) runJob(ctx context.Context, job *client.ClaimedJob) {
|
||||||
|
log.Printf("[job %s] starting render", job.JobID)
|
||||||
|
|
||||||
|
// ── Step 1: Download .aep template ───────────────────────────────────────
|
||||||
|
aepPath := ""
|
||||||
|
if job.AEPDownloadURL != "" && a.cfg.AEPath != "" {
|
||||||
|
localAEP := filepath.Join(a.cfg.WorkDir, "templates", job.JobID, "template.aep")
|
||||||
|
dlCtx, dlCancel := context.WithTimeout(ctx, 10*time.Minute)
|
||||||
|
n, dlErr := runner.DownloadFile(dlCtx, job.AEPDownloadURL, localAEP)
|
||||||
|
dlCancel()
|
||||||
|
if dlErr != nil {
|
||||||
|
log.Printf("[job %s] AEP download failed (%v) — falling back to mock", job.JobID, dlErr)
|
||||||
|
} else {
|
||||||
|
log.Printf("[job %s] AEP downloaded (%d bytes) → %s", job.JobID, n, localAEP)
|
||||||
|
aepPath = localAEP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rJob := &runner.Job{
|
||||||
|
JobID: job.JobID,
|
||||||
|
SavedProjectID: job.SavedProjectID,
|
||||||
|
Quality: job.Quality,
|
||||||
|
Resolution: job.Resolution,
|
||||||
|
FrameRate: job.FrameRate,
|
||||||
|
HasMusic: job.HasMusic,
|
||||||
|
HasVoiceover: job.HasVoiceover,
|
||||||
|
AEPFilePath: aepPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress := func(ctx context.Context, pct int, msg string) error {
|
||||||
|
log.Printf("[job %s] %d%% %s", job.JobID, pct, msg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
onPreview := func(ctx context.Context, imageB64 string) error {
|
||||||
|
pvCtx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := a.orch.UpdatePreview(pvCtx, job.JobID, imageB64); err != nil {
|
||||||
|
log.Printf("[job %s] preview push error: %v", job.JobID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 2: Render ───────────────────────────────────────────────────────
|
||||||
|
outputPath, err := runner.Run(ctx, a.cfg.AEPath, a.cfg.WorkDir, rJob, onProgress, onPreview)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
log.Printf("[job %s] render cancelled", job.JobID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[job %s] render failed: %v", job.JobID, err)
|
||||||
|
failCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if ferr := a.orch.Fail(failCtx, job.JobID, err.Error(), "Rendering"); ferr != nil {
|
||||||
|
log.Printf("[job %s] fail report error: %v", job.JobID, ferr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[job %s] render done → %s", job.JobID, outputPath)
|
||||||
|
|
||||||
|
// ── Step 3: Get presigned upload URL + upload output to MinIO ─────────────
|
||||||
|
var exportID *string
|
||||||
|
uploadCtx, uploadCancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
|
defer uploadCancel()
|
||||||
|
|
||||||
|
uploadInfo, urlErr := a.orch.GetOutputUploadURL(uploadCtx, job.JobID)
|
||||||
|
if urlErr != nil {
|
||||||
|
log.Printf("[job %s] get upload URL failed: %v — completing without export", job.JobID, urlErr)
|
||||||
|
} else {
|
||||||
|
log.Printf("[job %s] uploading output to %s", job.JobID, uploadInfo.ObjectKey)
|
||||||
|
if _, upErr := runner.UploadFile(uploadCtx, uploadInfo.UploadURL, outputPath); upErr != nil {
|
||||||
|
log.Printf("[job %s] upload failed: %v — completing without export", job.JobID, upErr)
|
||||||
|
} else {
|
||||||
|
log.Printf("[job %s] upload complete (export %s)", job.JobID, uploadInfo.ExportID)
|
||||||
|
exportID = &uploadInfo.ExportID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 4: Report complete ───────────────────────────────────────────────
|
||||||
|
completeCtx, completeCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer completeCancel()
|
||||||
|
if err := a.orch.Complete(completeCtx, job.JobID, exportID); err != nil {
|
||||||
|
log.Printf("[job %s] complete report error: %v", job.JobID, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("[job %s] reported as completed (export=%v)", job.JobID, exportID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Health endpoint ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (a *Agent) serveHealth(ctx context.Context) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status, jobID := a.getStatus()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
resp := map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"node_id": a.cfg.NodeID,
|
||||||
|
"status": status,
|
||||||
|
"current_job": jobID,
|
||||||
|
"version": a.cfg.AgentVersion,
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
|
})
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: fmt.Sprintf(":%d", a.cfg.ListenPort),
|
||||||
|
Handler: mux,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
_ = srv.Shutdown(context.Background())
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Printf("health endpoint listening on :%d", a.cfg.ListenPort)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Printf("health server error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module github.com/flatrender/node-agent
|
||||||
|
|
||||||
|
go 1.25
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
// Package client provides a typed HTTP client for the V2 render orchestrator's
|
||||||
|
// internal (node-agent) API. All requests are authenticated via the shared
|
||||||
|
// X-Node-Signature header.
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client talks to the V2 render orchestrator.
|
||||||
|
type Client struct {
|
||||||
|
base string
|
||||||
|
secret string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a Client targeting the given base URL (e.g. "http://gateway:8080").
|
||||||
|
func New(baseURL, nodeHMACSecret string) *Client {
|
||||||
|
return &Client{
|
||||||
|
base: strings.TrimRight(baseURL, "/"),
|
||||||
|
secret: nodeHMACSecret,
|
||||||
|
http: &http.Client{Timeout: 15 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Request helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (c *Client) do(ctx context.Context, method, path string, body any) (*http.Response, error) {
|
||||||
|
var bodyReader io.Reader
|
||||||
|
if body != nil {
|
||||||
|
b, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal: %w", err)
|
||||||
|
}
|
||||||
|
bodyReader = bytes.NewReader(b)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, c.base+path, bodyReader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("X-Node-Signature", c.secret)
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
return c.http.Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSON(resp *http.Response, out any) error {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if out == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.NewDecoder(resp.Body).Decode(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Domain types ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// OnlineRequest is sent once on startup to mark the node Ready.
|
||||||
|
type OnlineRequest struct {
|
||||||
|
NodeAgentVersion string `json:"node_agent_version"`
|
||||||
|
CurrentAEVersion string `json:"current_ae_version"`
|
||||||
|
AvailableAEVersions []string `json:"available_ae_versions"`
|
||||||
|
RamGB *int `json:"ram_gb,omitempty"`
|
||||||
|
CPUCores *int `json:"cpu_cores,omitempty"`
|
||||||
|
CacheUsedGB *int `json:"cache_used_gb,omitempty"`
|
||||||
|
CachedTemplateMD5s []string `json:"cached_template_md5s"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HeartbeatRequest is sent every HeartbeatIntervalSec seconds.
|
||||||
|
type HeartbeatRequest struct {
|
||||||
|
NodeID string `json:"node_id"`
|
||||||
|
Status string `json:"status"` // Ready | Busy
|
||||||
|
CPUPct *int `json:"cpu_pct,omitempty"`
|
||||||
|
RAMAvailableMB *int `json:"ram_available_mb,omitempty"`
|
||||||
|
AERunning *bool `json:"ae_running,omitempty"`
|
||||||
|
CurrentJobID *string `json:"current_job_id,omitempty"`
|
||||||
|
CacheUsedGB *int `json:"cache_used_gb,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HeartbeatResponse carries optional commands from the orchestrator.
|
||||||
|
type HeartbeatResponse struct {
|
||||||
|
NextHeartbeatInSec int `json:"next_heartbeat_in_sec"`
|
||||||
|
PendingCommands []any `json:"pending_commands"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimJobRequest asks the orchestrator for the next queued job.
|
||||||
|
type ClaimJobRequest struct {
|
||||||
|
NodeID string `json:"node_id"`
|
||||||
|
Region string `json:"region,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimedJob is the response when a job is successfully claimed.
|
||||||
|
type ClaimedJob struct {
|
||||||
|
JobID string `json:"job_id"`
|
||||||
|
SavedProjectID string `json:"saved_project_id"`
|
||||||
|
Quality string `json:"quality"`
|
||||||
|
Resolution string `json:"resolution"`
|
||||||
|
FrameRate int `json:"frame_rate"`
|
||||||
|
HasMusic bool `json:"has_music"`
|
||||||
|
HasVoiceover bool `json:"has_voiceover"`
|
||||||
|
// AEPDownloadURL is a presigned MinIO GET URL for the .aep template file.
|
||||||
|
// Empty when the template has not been uploaded yet — triggers mock render.
|
||||||
|
AEPDownloadURL string `json:"aep_download_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutputUploadURLResponse is returned by GetOutputUploadURL.
|
||||||
|
type OutputUploadURLResponse struct {
|
||||||
|
ExportID string `json:"export_id"`
|
||||||
|
UploadURL string `json:"upload_url"`
|
||||||
|
ObjectKey string `json:"object_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProgressRequest reports render progress (frame-level) for a job.
|
||||||
|
type ProgressRequest struct {
|
||||||
|
FrameJobID string `json:"frame_job_id"`
|
||||||
|
FrameNumber int `json:"frame_number"`
|
||||||
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteRequest marks a job as Done.
|
||||||
|
type CompleteRequest struct {
|
||||||
|
ExportID *string `json:"export_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FailRequest marks a job as Failed.
|
||||||
|
type FailRequest struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
AtStep string `json:"at_step,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CrashRequest reports a node crash.
|
||||||
|
type CrashRequest struct {
|
||||||
|
NodeID string `json:"node_id"`
|
||||||
|
LastKnownFrame *int `json:"last_known_frame,omitempty"`
|
||||||
|
CrashSignal *string `json:"crash_signal,omitempty"`
|
||||||
|
ErrorLogTail *string `json:"error_log_tail,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API methods ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Online marks the node as Ready on startup.
|
||||||
|
func (c *Client) Online(ctx context.Context, nodeID string, req OnlineRequest) error {
|
||||||
|
resp, err := c.do(ctx, http.MethodPost,
|
||||||
|
fmt.Sprintf("/v1/internal/nodes/%s/online", nodeID), req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("online: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heartbeat sends a heartbeat and returns the orchestrator's response.
|
||||||
|
func (c *Client) Heartbeat(ctx context.Context, nodeID string, req HeartbeatRequest) (*HeartbeatResponse, error) {
|
||||||
|
resp, err := c.do(ctx, http.MethodPost,
|
||||||
|
fmt.Sprintf("/v1/internal/nodes/%s/heartbeat", nodeID), req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("heartbeat: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var out HeartbeatResponse
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimJob atomically claims the next queued render job.
|
||||||
|
// Returns (nil, nil) when the queue is empty (204 No Content).
|
||||||
|
func (c *Client) ClaimJob(ctx context.Context, nodeID, region string) (*ClaimedJob, error) {
|
||||||
|
resp, err := c.do(ctx, http.MethodPost, "/v1/internal/render/jobs/claim",
|
||||||
|
ClaimJobRequest{NodeID: nodeID, Region: region})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode == http.StatusNoContent {
|
||||||
|
return nil, nil // nothing queued
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("claim: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var job ClaimedJob
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&job); err != nil {
|
||||||
|
return nil, fmt.Errorf("claim decode: %w", err)
|
||||||
|
}
|
||||||
|
return &job, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePreview sends a base64-encoded preview frame to the orchestrator.
|
||||||
|
// Errors are non-fatal — the UI simply won't update the preview image.
|
||||||
|
func (c *Client) UpdatePreview(ctx context.Context, jobID, imageB64 string) error {
|
||||||
|
resp, err := c.do(ctx, http.MethodPost,
|
||||||
|
fmt.Sprintf("/v1/internal/render/jobs/%s/preview", jobID),
|
||||||
|
map[string]string{"image_b64": imageB64})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("preview: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOutputUploadURL asks the orchestrator to allocate an Export row and
|
||||||
|
// return a presigned MinIO PUT URL for the rendered output file.
|
||||||
|
func (c *Client) GetOutputUploadURL(ctx context.Context, jobID string) (*OutputUploadURLResponse, error) {
|
||||||
|
resp, err := c.do(ctx, http.MethodPost,
|
||||||
|
fmt.Sprintf("/v1/internal/render/jobs/%s/output-upload-url", jobID), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("output-upload-url: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var out OutputUploadURLResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete marks a render job as Done.
|
||||||
|
func (c *Client) Complete(ctx context.Context, jobID string, exportID *string) error {
|
||||||
|
resp, err := c.do(ctx, http.MethodPost,
|
||||||
|
fmt.Sprintf("/v1/internal/render/jobs/%s/complete", jobID),
|
||||||
|
CompleteRequest{ExportID: exportID})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("complete: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail marks a render job as Failed.
|
||||||
|
func (c *Client) Fail(ctx context.Context, jobID, reason, atStep string) error {
|
||||||
|
resp, err := c.do(ctx, http.MethodPost,
|
||||||
|
fmt.Sprintf("/v1/internal/render/jobs/%s/fail", jobID),
|
||||||
|
FailRequest{Reason: reason, AtStep: atStep})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("fail: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReportCrash reports a node crash for the given job.
|
||||||
|
func (c *Client) ReportCrash(ctx context.Context, jobID string, req CrashRequest) error {
|
||||||
|
resp, err := c.do(ctx, http.MethodPost,
|
||||||
|
fmt.Sprintf("/v1/internal/render/jobs/%s/crash", jobID), req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("crash: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Package config loads node-agent runtime configuration from environment variables.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds all runtime settings for the node agent.
|
||||||
|
type Config struct {
|
||||||
|
// NodeID is the UUID of this render node, registered in the orchestrator.
|
||||||
|
// Must match a row in render.render_nodes.
|
||||||
|
NodeID string
|
||||||
|
|
||||||
|
// OrchestratorURL is the base URL of the V2 API gateway (internal network).
|
||||||
|
// Example: http://gateway:8080 or http://172.30.0.5:8088
|
||||||
|
OrchestratorURL string
|
||||||
|
|
||||||
|
// NodeHMACSecret is the shared secret sent as X-Node-Signature header.
|
||||||
|
// Must match NODE_HMAC_SECRET in the render-svc environment.
|
||||||
|
NodeHMACSecret string
|
||||||
|
|
||||||
|
// Region is the datacenter/region label for this node (e.g. "iran-tehran-1").
|
||||||
|
// The orchestrator uses it to route region-preferred jobs to this node.
|
||||||
|
Region string
|
||||||
|
|
||||||
|
// AEPath is the full path to the aerender.exe binary.
|
||||||
|
// Example: C:\Program Files\Adobe\Adobe After Effects 2024\Support Files\aerender.exe
|
||||||
|
// Leave empty to use mock rendering (for development / testing without AE).
|
||||||
|
AEPath string
|
||||||
|
|
||||||
|
// WorkDir is the scratch directory for render temp files and AE project copies.
|
||||||
|
WorkDir string
|
||||||
|
|
||||||
|
// HeartbeatIntervalSec is how often the agent sends a heartbeat to the orchestrator.
|
||||||
|
HeartbeatIntervalSec int
|
||||||
|
|
||||||
|
// PollIntervalSec is how long the agent waits between job-claim attempts when idle.
|
||||||
|
PollIntervalSec int
|
||||||
|
|
||||||
|
// AgentVersion is the semantic version string reported to the orchestrator.
|
||||||
|
AgentVersion string
|
||||||
|
|
||||||
|
// AEVersion is the After Effects version string reported to the orchestrator.
|
||||||
|
// Example: "2024"
|
||||||
|
AEVersion string
|
||||||
|
|
||||||
|
// ListenPort is the port for the agent's own HTTP health endpoint.
|
||||||
|
ListenPort int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads configuration from environment variables, returning an error
|
||||||
|
// if any required variable is missing.
|
||||||
|
func Load() (*Config, error) {
|
||||||
|
c := &Config{
|
||||||
|
NodeID: os.Getenv("NODE_ID"),
|
||||||
|
OrchestratorURL: getEnv("ORCHESTRATOR_URL", "http://localhost:8088"),
|
||||||
|
NodeHMACSecret: getEnv("NODE_HMAC_SECRET", "node-secret-change-me"),
|
||||||
|
Region: getEnv("NODE_REGION", ""),
|
||||||
|
AEPath: getEnv("AE_PATH", ""),
|
||||||
|
WorkDir: getEnv("WORK_DIR", os.TempDir()),
|
||||||
|
AgentVersion: getEnv("AGENT_VERSION", "0.1.0"),
|
||||||
|
AEVersion: getEnv("AE_VERSION", "2024"),
|
||||||
|
HeartbeatIntervalSec: getInt("HEARTBEAT_INTERVAL_SEC", 5),
|
||||||
|
PollIntervalSec: getInt("POLL_INTERVAL_SEC", 3),
|
||||||
|
ListenPort: getInt("LISTEN_PORT", 7777),
|
||||||
|
}
|
||||||
|
if c.NodeID == "" {
|
||||||
|
return nil, fmt.Errorf("NODE_ID environment variable is required")
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnv(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func getInt(key string, fallback int) int {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// download.go fetches a remote file (presigned MinIO URL or any HTTP URL) and
|
||||||
|
// saves it to a local path. Uses stdlib only — no external HTTP client needed.
|
||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DownloadFile fetches the resource at rawURL and writes it to destPath,
|
||||||
|
// creating parent directories as needed. Returns the number of bytes written.
|
||||||
|
func DownloadFile(ctx context.Context, rawURL, destPath string) (int64, error) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
|
||||||
|
return 0, fmt.Errorf("mkdir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("new request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("GET: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return 0, fmt.Errorf("server returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Create(destPath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("create file: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
n, err := io.Copy(f, resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("write: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadFile PUTs a local file to a presigned MinIO/S3 URL.
|
||||||
|
// MinIO presigned PUT expects the raw bytes in the request body with
|
||||||
|
// Content-Type application/octet-stream.
|
||||||
|
func UploadFile(ctx context.Context, rawURL, filePath string) (int64, error) {
|
||||||
|
f, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("open: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
stat, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("stat: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, rawURL, f)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("new request: %w", err)
|
||||||
|
}
|
||||||
|
req.ContentLength = stat.Size()
|
||||||
|
req.Header.Set("Content-Type", "application/octet-stream")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("PUT: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// MinIO returns 200 on successful PUT of presigned objects
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return 0, fmt.Errorf("upload server returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return stat.Size(), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// preview.go generates a small PNG preview frame for the live-preview UI.
|
||||||
|
// Uses only the Go standard library — no external image dependencies.
|
||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
|
"image/png"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
previewW = 320
|
||||||
|
previewH = 180
|
||||||
|
)
|
||||||
|
|
||||||
|
// GeneratePreviewB64 returns a base64-encoded 320×180 PNG that visualises the
|
||||||
|
// current render progress. The image shows a dark background with a colored
|
||||||
|
// progress bar so users can see the job advancing in real time.
|
||||||
|
//
|
||||||
|
// percent should be 0-100.
|
||||||
|
func GeneratePreviewB64(percent int, quality, resolution string) string {
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, previewW, previewH))
|
||||||
|
|
||||||
|
// Background: dark slate
|
||||||
|
bgColor := color.RGBA{R: 15, G: 17, B: 30, A: 255}
|
||||||
|
draw.Draw(img, img.Bounds(), &image.Uniform{bgColor}, image.Point{}, draw.Src)
|
||||||
|
|
||||||
|
// Progress bar track (slightly lighter)
|
||||||
|
trackColor := color.RGBA{R: 30, G: 34, B: 56, A: 255}
|
||||||
|
barY := previewH/2 - 6
|
||||||
|
barH := 12
|
||||||
|
trackRect := image.Rect(20, barY, previewW-20, barY+barH)
|
||||||
|
draw.Draw(img, trackRect, &image.Uniform{trackColor}, image.Point{}, draw.Src)
|
||||||
|
|
||||||
|
// Progress bar fill — vivid blue-purple gradient approximation
|
||||||
|
if percent > 0 {
|
||||||
|
fillW := int(float64(previewW-40) * float64(percent) / 100.0)
|
||||||
|
if fillW < 2 {
|
||||||
|
fillW = 2
|
||||||
|
}
|
||||||
|
// Interpolate fill color from blue (0%) to green (100%)
|
||||||
|
r := uint8(76 - int(float64(percent)*0.3))
|
||||||
|
g := uint8(110 + int(float64(percent)*0.8))
|
||||||
|
b := uint8(245 - int(float64(percent)*1.3))
|
||||||
|
fillColor := color.RGBA{R: r, G: g, B: b, A: 255}
|
||||||
|
fillRect := image.Rect(20, barY, 20+fillW, barY+barH)
|
||||||
|
draw.Draw(img, fillRect, &image.Uniform{fillColor}, image.Point{}, draw.Src)
|
||||||
|
|
||||||
|
// Bright leading edge (1px)
|
||||||
|
edgeColor := color.RGBA{R: 200, G: 230, B: 255, A: 255}
|
||||||
|
edgeRect := image.Rect(20+fillW-1, barY, 20+fillW, barY+barH)
|
||||||
|
draw.Draw(img, edgeRect, &image.Uniform{edgeColor}, image.Point{}, draw.Src)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quality/resolution indicator dots
|
||||||
|
dotColors := []color.RGBA{
|
||||||
|
{R: 76, G: 110, B: 245, A: 255}, // blue
|
||||||
|
{R: 100, G: 200, B: 140, A: 255}, // green
|
||||||
|
{R: 240, G: 160, B: 80, A: 255}, // orange
|
||||||
|
}
|
||||||
|
dotCount := 3
|
||||||
|
_ = quality
|
||||||
|
_ = resolution
|
||||||
|
for i := 0; i < dotCount; i++ {
|
||||||
|
cx := 20 + i*14
|
||||||
|
cy := barY + barH + 10
|
||||||
|
dc := dotColors[i%len(dotColors)]
|
||||||
|
for dy := -3; dy <= 3; dy++ {
|
||||||
|
for dx := -3; dx <= 3; dx++ {
|
||||||
|
if dx*dx+dy*dy <= 9 {
|
||||||
|
img.SetRGBA(cx+dx, cy+dy, dc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode to PNG
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_ = png.Encode(&buf, img)
|
||||||
|
return base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
// Package runner executes After Effects render jobs and streams progress back
|
||||||
|
// via the provided callbacks. When AE_PATH is empty, a mock render is used
|
||||||
|
// (useful for CI and dev environments without a licensed AE installation).
|
||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProgressFn is called periodically during rendering with (percent 0-100, message).
|
||||||
|
type ProgressFn func(ctx context.Context, percent int, message string) error
|
||||||
|
|
||||||
|
// PreviewFn is called each time a new preview frame is ready.
|
||||||
|
// The argument is a base64-encoded PNG. Errors are non-fatal.
|
||||||
|
type PreviewFn func(ctx context.Context, imageB64 string) error
|
||||||
|
|
||||||
|
// Job holds the parameters for a single render.
|
||||||
|
type Job struct {
|
||||||
|
JobID string
|
||||||
|
SavedProjectID string
|
||||||
|
Quality string
|
||||||
|
Resolution string
|
||||||
|
FrameRate int
|
||||||
|
HasMusic bool
|
||||||
|
HasVoiceover bool
|
||||||
|
// AEPFilePath is the local path to the downloaded .aep project file.
|
||||||
|
// In a full implementation the agent downloads this from MinIO before calling Run.
|
||||||
|
AEPFilePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes the render job, calling onProgress and onPreview as it advances.
|
||||||
|
// Returns the path to the output MP4 file on success.
|
||||||
|
func Run(ctx context.Context, aePath, workDir string, job *Job, onProgress ProgressFn, onPreview PreviewFn) (string, error) {
|
||||||
|
outputDir := filepath.Join(workDir, "renders", job.JobID)
|
||||||
|
if err := os.MkdirAll(outputDir, 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("create output dir: %w", err)
|
||||||
|
}
|
||||||
|
outputPath := filepath.Join(outputDir, "output.mp4")
|
||||||
|
|
||||||
|
if aePath == "" {
|
||||||
|
return mockRender(ctx, job, outputPath, onProgress, onPreview)
|
||||||
|
}
|
||||||
|
return aeRender(ctx, aePath, job, outputPath, onProgress, onPreview)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mock render (no AE installed) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
func mockRender(ctx context.Context, job *Job, outputPath string, onProgress ProgressFn, onPreview PreviewFn) (string, error) {
|
||||||
|
log.Printf("[mock] starting render for job %s (%s %s %dfps)", job.JobID, job.Quality, job.Resolution, job.FrameRate)
|
||||||
|
|
||||||
|
steps := []struct {
|
||||||
|
pct int
|
||||||
|
msg string
|
||||||
|
}{
|
||||||
|
{5, "Preparing project…"},
|
||||||
|
{15, "Loading template…"},
|
||||||
|
{30, "Rendering frames…"},
|
||||||
|
{50, "Rendering frames… (50%)"},
|
||||||
|
{70, "Rendering frames… (70%)"},
|
||||||
|
{85, "Encoding MP4…"},
|
||||||
|
{95, "Uploading output…"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range steps {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return "", ctx.Err()
|
||||||
|
case <-time.After(800 * time.Millisecond):
|
||||||
|
}
|
||||||
|
if err := onProgress(ctx, s.pct, s.msg); err != nil {
|
||||||
|
log.Printf("[mock] progress callback error: %v", err)
|
||||||
|
}
|
||||||
|
// Generate and push a preview frame at each step
|
||||||
|
if onPreview != nil {
|
||||||
|
b64 := GeneratePreviewB64(s.pct, job.Quality, job.Resolution)
|
||||||
|
if err := onPreview(ctx, b64); err != nil {
|
||||||
|
log.Printf("[mock] preview callback error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("[mock] %d%% — %s", s.pct, s.msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write a placeholder file so the path is valid
|
||||||
|
if err := os.WriteFile(outputPath, []byte("mock-render-output"), 0o644); err != nil {
|
||||||
|
return "", fmt.Errorf("write mock output: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("[mock] render complete: %s", outputPath)
|
||||||
|
return outputPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Real AE render via aerender.exe ──────────────────────────────────────────
|
||||||
|
|
||||||
|
func aeRender(ctx context.Context, aePath string, job *Job, outputPath string, onProgress ProgressFn, onPreview PreviewFn) (string, error) {
|
||||||
|
if job.AEPFilePath == "" {
|
||||||
|
return "", fmt.Errorf("AEPFilePath is required for real AE render")
|
||||||
|
}
|
||||||
|
|
||||||
|
// aerender flags:
|
||||||
|
// -project <path.aep>
|
||||||
|
// -output <output.mp4>
|
||||||
|
args := []string{
|
||||||
|
"-project", job.AEPFilePath,
|
||||||
|
"-output", outputPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[ae] running: %s %v", aePath, args)
|
||||||
|
cmd := exec.CommandContext(ctx, aePath, args...)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return "", fmt.Errorf("start aerender: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll process while alive — aerender does not expose machine-readable progress.
|
||||||
|
// We advance the progress indicator every 10 seconds until the process exits.
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- cmd.Wait() }()
|
||||||
|
|
||||||
|
_ = onProgress(ctx, 10, "After Effects starting…")
|
||||||
|
pct := 10
|
||||||
|
ticker := time.NewTicker(10 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// Generate preview frames every 30 seconds during real AE render.
|
||||||
|
// In a full implementation this would screenshot the AE composition output.
|
||||||
|
previewTicker := time.NewTicker(30 * time.Second)
|
||||||
|
defer previewTicker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("aerender exit: %w", err)
|
||||||
|
}
|
||||||
|
_ = onProgress(ctx, 95, "Encoding complete")
|
||||||
|
return outputPath, nil
|
||||||
|
case <-ticker.C:
|
||||||
|
if pct < 90 {
|
||||||
|
pct += 5
|
||||||
|
}
|
||||||
|
_ = onProgress(ctx, pct, fmt.Sprintf("Rendering… %d%%", pct))
|
||||||
|
case <-previewTicker.C:
|
||||||
|
if onPreview != nil {
|
||||||
|
b64 := GeneratePreviewB64(pct, job.Quality, job.Resolution)
|
||||||
|
if err := onPreview(ctx, b64); err != nil {
|
||||||
|
log.Printf("[ae] preview push error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
return "", ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ func main() {
|
|||||||
minioSecretKey := getEnv("MINIO_SECRET_KEY", "minioadmin")
|
minioSecretKey := getEnv("MINIO_SECRET_KEY", "minioadmin")
|
||||||
minioUseSSL := getEnv("MINIO_USE_SSL", "false") == "true"
|
minioUseSSL := getEnv("MINIO_USE_SSL", "false") == "true"
|
||||||
minioBucket := getEnv("MINIO_BUCKET", "flatrender-exports")
|
minioBucket := getEnv("MINIO_BUCKET", "flatrender-exports")
|
||||||
|
minioTemplatesBucket := getEnv("MINIO_TEMPLATES_BUCKET", "flatrender-templates")
|
||||||
notificationURL := getEnv("NOTIFICATION_URL", "http://localhost:8080")
|
notificationURL := getEnv("NOTIFICATION_URL", "http://localhost:8080")
|
||||||
serviceToken := getEnv("SERVICE_TOKEN", "internal-service-secret")
|
serviceToken := getEnv("SERVICE_TOKEN", "internal-service-secret")
|
||||||
port := getEnv("PORT", "8080")
|
port := getEnv("PORT", "8080")
|
||||||
@@ -63,7 +64,7 @@ func main() {
|
|||||||
snapH := handlers.NewSnapshotHandler(store)
|
snapH := handlers.NewSnapshotHandler(store)
|
||||||
exportH := handlers.NewExportHandler(store, mc, minioBucket)
|
exportH := handlers.NewExportHandler(store, mc, minioBucket)
|
||||||
nodeH := handlers.NewNodeHandler(store)
|
nodeH := handlers.NewNodeHandler(store)
|
||||||
internalH := handlers.NewInternalHandler(store, notifyClient)
|
internalH := handlers.NewInternalHandler(store, notifyClient, mc, minioTemplatesBucket, minioBucket)
|
||||||
|
|
||||||
// ── Router ────────────────────────────────────────────────────────────────
|
// ── Router ────────────────────────────────────────────────────────────────
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
@@ -136,6 +137,9 @@ func main() {
|
|||||||
internal.POST("/nodes/:node_id/heartbeat", internalH.Heartbeat)
|
internal.POST("/nodes/:node_id/heartbeat", internalH.Heartbeat)
|
||||||
internal.POST("/nodes/:node_id/online", internalH.Online)
|
internal.POST("/nodes/:node_id/online", internalH.Online)
|
||||||
internal.POST("/nodes/:node_id/cache-update", internalH.CacheUpdate)
|
internal.POST("/nodes/:node_id/cache-update", internalH.CacheUpdate)
|
||||||
|
internal.POST("/render/jobs/claim", internalH.Claim)
|
||||||
|
internal.POST("/render/jobs/:job_id/preview", internalH.Preview)
|
||||||
|
internal.POST("/render/jobs/:job_id/output-upload-url", internalH.OutputUploadURL)
|
||||||
internal.POST("/render/jobs/:job_id/frames", internalH.FrameProgress)
|
internal.POST("/render/jobs/:job_id/frames", internalH.FrameProgress)
|
||||||
internal.POST("/render/jobs/:job_id/complete", internalH.Complete)
|
internal.POST("/render/jobs/:job_id/complete", internalH.Complete)
|
||||||
internal.POST("/render/jobs/:job_id/fail", internalH.Fail)
|
internal.POST("/render/jobs/:job_id/fail", internalH.Fail)
|
||||||
|
|||||||
@@ -463,6 +463,125 @@ func (s *Store) getJobByIDInternal(ctx context.Context, id uuid.UUID) (*models.R
|
|||||||
return jobs[0], nil
|
return jobs[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ClaimJob atomically picks the highest-priority Queued job (optionally filtered
|
||||||
|
// by region) and moves it to Preparing, setting the current_job_id on the node.
|
||||||
|
// Returns (nil, nil) when there is nothing to do.
|
||||||
|
func (s *Store) ClaimJob(ctx context.Context, nodeID uuid.UUID, region string) (*models.RenderJob, error) {
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
|
q := `SELECT id FROM render.render_jobs
|
||||||
|
WHERE step = 'Queued'::render_step`
|
||||||
|
args := []any{}
|
||||||
|
argIdx := 1
|
||||||
|
if region != "" {
|
||||||
|
q += fmt.Sprintf(" AND (region IS NULL OR region = $%d)", argIdx)
|
||||||
|
args = append(args, region)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
q += " ORDER BY priority_score DESC, queued_at ASC LIMIT 1 FOR UPDATE SKIP LOCKED"
|
||||||
|
|
||||||
|
var jobID uuid.UUID
|
||||||
|
if err := tx.QueryRow(ctx, q, args...).Scan(&jobID); err != nil {
|
||||||
|
if err.Error() == "no rows in result set" {
|
||||||
|
return nil, nil // nothing to do
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance to Preparing and assign to this node
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
UPDATE render.render_jobs SET
|
||||||
|
step = 'Preparing'::render_step,
|
||||||
|
started_at = COALESCE(started_at, NOW()),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1`, jobID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
UPDATE render.render_nodes SET
|
||||||
|
status = 'Busy'::node_status,
|
||||||
|
current_job_id = $1,
|
||||||
|
job_started_at = NOW(),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $2`, jobID, nodeID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.getJobByIDInternal(ctx, jobID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateExportForJob allocates a new Export row for a completed render job.
|
||||||
|
// The export starts with a placeholder path `exports/{export_id}/output.mp4`.
|
||||||
|
// The node agent uploads the MP4 to that MinIO path, then calls CompleteJob
|
||||||
|
// with the returned export_id.
|
||||||
|
func (s *Store) CreateExportForJob(ctx context.Context, jobID uuid.UUID) (*models.Export, error) {
|
||||||
|
// Look up the job to get tenant/user/project context
|
||||||
|
job, err := s.getJobByIDInternal(ctx, jobID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("job not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
exportID := uuid.New()
|
||||||
|
path := fmt.Sprintf("exports/%s/output.mp4", exportID)
|
||||||
|
now := time.Now()
|
||||||
|
autoDelete := now.AddDate(0, 0, 30) // 30-day retention
|
||||||
|
|
||||||
|
_, err = s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO render.exports
|
||||||
|
(id, tenant_id, user_id, saved_project_id, original_project_id,
|
||||||
|
render_job_id, path, file_extension, file_type, render_quality,
|
||||||
|
create_type, size_bytes, produce_date, auto_delete_date,
|
||||||
|
delete_notified, created_at)
|
||||||
|
VALUES
|
||||||
|
($1, $2, $3, $4, $5,
|
||||||
|
$6, $7, 'mp4', 'video', $8,
|
||||||
|
'render', 0, $9, $10,
|
||||||
|
false, $9)`,
|
||||||
|
exportID, job.TenantID, job.UserID, job.SavedProjectID, job.OriginalProjectID,
|
||||||
|
job.ID, path, job.Quality,
|
||||||
|
now, autoDelete,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create export: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.Export{
|
||||||
|
ID: exportID,
|
||||||
|
TenantID: job.TenantID,
|
||||||
|
UserID: job.UserID,
|
||||||
|
SavedProjectID: job.SavedProjectID,
|
||||||
|
Path: path,
|
||||||
|
FileExtension: "mp4",
|
||||||
|
FileType: "video",
|
||||||
|
RenderQuality: job.Quality,
|
||||||
|
CreateType: "render",
|
||||||
|
ProduceDate: now,
|
||||||
|
AutoDeleteDate: autoDelete,
|
||||||
|
CreatedAt: now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateJobPreview stores a base64-encoded preview frame for a running job.
|
||||||
|
// Called by the node agent every N frames to power the live preview UI.
|
||||||
|
func (s *Store) UpdateJobPreview(ctx context.Context, jobID uuid.UUID, imageB64 string) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE render.render_jobs
|
||||||
|
SET image_preview_b64 = $1, updated_at = NOW()
|
||||||
|
WHERE id = $2
|
||||||
|
AND step NOT IN ('Done'::render_step, 'Failed'::render_step, 'Cancelled'::render_step)`,
|
||||||
|
imageB64, jobID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) CancelJob(ctx context.Context, id, userID uuid.UUID) (bool, error) {
|
func (s *Store) CancelJob(ctx context.Context, id, userID uuid.UUID) (bool, error) {
|
||||||
tag, err := s.pool.Exec(ctx, `
|
tag, err := s.pool.Exec(ctx, `
|
||||||
UPDATE render.render_jobs
|
UPDATE render.render_jobs
|
||||||
|
|||||||
@@ -1,22 +1,35 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/flatrender/render-svc/internal/db"
|
"github.com/flatrender/render-svc/internal/db"
|
||||||
"github.com/flatrender/render-svc/internal/models"
|
"github.com/flatrender/render-svc/internal/models"
|
||||||
"github.com/flatrender/render-svc/internal/notifier"
|
"github.com/flatrender/render-svc/internal/notifier"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/minio/minio-go/v7"
|
||||||
)
|
)
|
||||||
|
|
||||||
type InternalHandler struct {
|
type InternalHandler struct {
|
||||||
store *db.Store
|
store *db.Store
|
||||||
notifier *notifier.Client // may be nil — notifications are best-effort
|
notifier *notifier.Client // may be nil — notifications are best-effort
|
||||||
|
minio *minio.Client
|
||||||
|
templatesBucket string // bucket that holds .aep project files
|
||||||
|
exportsBucket string // bucket that receives rendered MP4 outputs
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewInternalHandler(store *db.Store, n *notifier.Client) *InternalHandler {
|
func NewInternalHandler(store *db.Store, n *notifier.Client, mc *minio.Client, templatesBucket, exportsBucket string) *InternalHandler {
|
||||||
return &InternalHandler{store: store, notifier: n}
|
return &InternalHandler{
|
||||||
|
store: store,
|
||||||
|
notifier: n,
|
||||||
|
minio: mc,
|
||||||
|
templatesBucket: templatesBucket,
|
||||||
|
exportsBucket: exportsBucket,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// completeRequest is the body for POST .../complete
|
// completeRequest is the body for POST .../complete
|
||||||
@@ -198,6 +211,112 @@ func (h *InternalHandler) ReplicaReady(c *gin.Context) {
|
|||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /v1/internal/render/jobs/:job_id/preview
|
||||||
|
// Node agent pushes a base64-encoded frame image so the frontend can show
|
||||||
|
// a live preview while the job is rendering.
|
||||||
|
func (h *InternalHandler) Preview(c *gin.Context) {
|
||||||
|
jobID, err := uuid.Parse(c.Param("job_id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid job_id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
ImageB64 string `json:"image_b64" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.store.UpdateJobPreview(c.Request.Context(), jobID, req.ImageB64); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /v1/internal/render/jobs/claim
|
||||||
|
// Node agent calls this to atomically claim the next queued job.
|
||||||
|
// Returns 204 when there is nothing queued (agent should back off and retry).
|
||||||
|
func (h *InternalHandler) Claim(c *gin.Context) {
|
||||||
|
var req models.ClaimJobRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
job, err := h.store.ClaimJob(c.Request.Context(), req.NodeID, req.Region)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if job == nil {
|
||||||
|
c.Status(http.StatusNoContent) // nothing queued
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate presigned AEP download URL. AEP files are stored at
|
||||||
|
// templates/{original_project_id}/template.aep in the templates bucket.
|
||||||
|
// Errors are non-fatal — node agent falls back to mock render when URL is empty.
|
||||||
|
aepURL := ""
|
||||||
|
if h.minio != nil {
|
||||||
|
objectKey := fmt.Sprintf("templates/%s/template.aep", job.OriginalProjectID)
|
||||||
|
purl, perr := h.minio.PresignedGetObject(
|
||||||
|
context.Background(), h.templatesBucket, objectKey,
|
||||||
|
2*time.Hour, nil,
|
||||||
|
)
|
||||||
|
if perr == nil {
|
||||||
|
aepURL = purl.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, models.ClaimedJob{
|
||||||
|
JobID: job.ID,
|
||||||
|
SavedProjectID: job.SavedProjectID,
|
||||||
|
Quality: job.Quality,
|
||||||
|
Resolution: job.Resolution,
|
||||||
|
FrameRate: job.FrameRate,
|
||||||
|
HasMusic: job.HasMusic,
|
||||||
|
HasVoiceover: job.HasVoiceover,
|
||||||
|
AEPDownloadURL: aepURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /v1/internal/render/jobs/:job_id/output-upload-url
|
||||||
|
// Node agent calls this after rendering to get a presigned MinIO PUT URL.
|
||||||
|
// Creates an Export record in the DB and returns the export_id + upload URL.
|
||||||
|
func (h *InternalHandler) OutputUploadURL(c *gin.Context) {
|
||||||
|
jobID, err := uuid.Parse(c.Param("job_id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid job_id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
export, err := h.store.CreateExportForJob(c.Request.Context(), jobID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
expiry := 2 * time.Hour
|
||||||
|
purl, err := h.minio.PresignedPutObject(
|
||||||
|
context.Background(), h.exportsBucket, export.Path, expiry,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, models.APIError{
|
||||||
|
Code: "presign_error",
|
||||||
|
Message: "could not generate upload URL",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, models.OutputUploadURLResponse{
|
||||||
|
ExportID: export.ID,
|
||||||
|
UploadURL: purl.String(),
|
||||||
|
ObjectKey: export.Path,
|
||||||
|
ExpiresAt: time.Now().Add(expiry),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// POST /v1/internal/nodes/:node_id/cache-update
|
// POST /v1/internal/nodes/:node_id/cache-update
|
||||||
func (h *InternalHandler) CacheUpdate(c *gin.Context) {
|
func (h *InternalHandler) CacheUpdate(c *gin.Context) {
|
||||||
nodeID, err := uuid.Parse(c.Param("node_id"))
|
nodeID, err := uuid.Parse(c.Param("node_id"))
|
||||||
|
|||||||
@@ -402,6 +402,32 @@ type CrashReportRequest struct {
|
|||||||
LogFileURL *string `json:"log_file_url"`
|
LogFileURL *string `json:"log_file_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ClaimJobRequest struct {
|
||||||
|
NodeID uuid.UUID `json:"node_id" binding:"required"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClaimedJob struct {
|
||||||
|
JobID uuid.UUID `json:"job_id"`
|
||||||
|
SavedProjectID uuid.UUID `json:"saved_project_id"`
|
||||||
|
Quality string `json:"quality"`
|
||||||
|
Resolution string `json:"resolution"`
|
||||||
|
FrameRate int `json:"frame_rate"`
|
||||||
|
HasMusic bool `json:"has_music"`
|
||||||
|
HasVoiceover bool `json:"has_voiceover"`
|
||||||
|
// AEPDownloadURL is a presigned MinIO GET URL for the .aep project file.
|
||||||
|
// Valid for 2 hours. Empty when the template is not yet uploaded.
|
||||||
|
AEPDownloadURL string `json:"aep_download_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutputUploadURLResponse is returned by POST .../output-upload-url.
|
||||||
|
type OutputUploadURLResponse struct {
|
||||||
|
ExportID uuid.UUID `json:"export_id"`
|
||||||
|
UploadURL string `json:"upload_url"`
|
||||||
|
ObjectKey string `json:"object_key"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type CacheUpdateRequest struct {
|
type CacheUpdateRequest struct {
|
||||||
Action string `json:"action" binding:"required"`
|
Action string `json:"action" binding:"required"`
|
||||||
ProjectID *uuid.UUID `json:"project_id"`
|
ProjectID *uuid.UUID `json:"project_id"`
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|||||||
ValidateAudience = true,
|
ValidateAudience = true,
|
||||||
ValidAudience = builder.Configuration["Jwt:Audience"],
|
ValidAudience = builder.Configuration["Jwt:Audience"],
|
||||||
ValidateLifetime = true,
|
ValidateLifetime = true,
|
||||||
ClockSkew = TimeSpan.FromSeconds(30)
|
ClockSkew = TimeSpan.FromSeconds(30),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { AiContentStudio } from "@/components/admin/AiContentStudio";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default function AdminAiPage() {
|
||||||
|
return <AiContentStudio />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AdminResource } from "@/components/admin/AdminResource";
|
||||||
|
import { blogsConfig } from "@/components/admin/admin-resources";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <AdminResource config={blogsConfig} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AdminResource } from "@/components/admin/AdminResource";
|
||||||
|
import { categoriesConfig } from "@/components/admin/admin-resources";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <AdminResource config={categoriesConfig} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AdminResource } from "@/components/admin/AdminResource";
|
||||||
|
import { fontsConfig } from "@/components/admin/admin-resources";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <AdminResource config={fontsConfig} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
import { getCurrentUser } from "@/lib/auth/session";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function AdminLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user || !user.is_admin) {
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
|
const t = await getTranslations("auto.appAdminLayout");
|
||||||
|
const links: { href: string; label: string }[] = [
|
||||||
|
{ href: "/admin/categories", label: t("categories") },
|
||||||
|
{ href: "/admin/tags", label: t("tags") },
|
||||||
|
{ href: "/admin/fonts", label: t("fonts") },
|
||||||
|
{ href: "/admin/blogs", label: t("blogs") },
|
||||||
|
{ href: "/admin/slides", label: t("slides") },
|
||||||
|
{ href: "/admin/ai", label: t("aiContent") },
|
||||||
|
{ href: "/admin/users", label: t("users") },
|
||||||
|
{ href: "/admin/plans", label: t("plans") },
|
||||||
|
{ href: "/admin/nodes", label: t("nodes") },
|
||||||
|
{ href: "/admin/renders", label: t("renderQueue") },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#0c0e1a] text-gray-200">
|
||||||
|
<nav className="border-b border-[#1e2235] bg-[#0f1120] px-6 py-3">
|
||||||
|
<div className="mx-auto flex max-w-7xl flex-wrap items-center gap-x-5 gap-y-2">
|
||||||
|
<span className="text-sm font-semibold text-white">{t("brand")}</span>
|
||||||
|
{links.map((l) => (
|
||||||
|
<a
|
||||||
|
key={l.href}
|
||||||
|
href={l.href}
|
||||||
|
className="text-sm text-gray-400 transition-colors hover:text-white"
|
||||||
|
>
|
||||||
|
{l.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
<a
|
||||||
|
href="/dashboard"
|
||||||
|
className="ml-auto text-xs text-gray-500 transition-colors hover:text-gray-300"
|
||||||
|
>
|
||||||
|
{t("backToDashboard")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main className="mx-auto max-w-7xl px-6 py-8">{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
import { adminGet } from "@/lib/api/admin-gateway";
|
||||||
|
import { NodesTable } from "@/components/admin/NodesTable";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
interface V2Node {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: "Online" | "Busy" | "Offline" | "Draining";
|
||||||
|
last_heartbeat: string;
|
||||||
|
active_job_id: string | null;
|
||||||
|
slots_total: number;
|
||||||
|
slots_used: number;
|
||||||
|
version: string | null;
|
||||||
|
tags: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface V2NodeList {
|
||||||
|
items: V2Node[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminNodesPage() {
|
||||||
|
const data = await adminGet<V2NodeList>("/v1/nodes?pageSize=100");
|
||||||
|
const nodes = data?.items ?? [];
|
||||||
|
const t = await getTranslations("auto.appAdminNodesPage");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-white">{t("title")}</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
{t("registered", { count: nodes.length })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NodesTable nodes={nodes} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function AdminRootPage() {
|
||||||
|
redirect("/admin/nodes");
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AdminResource } from "@/components/admin/AdminResource";
|
||||||
|
import { plansConfig } from "@/components/admin/admin-resources";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <AdminResource config={plansConfig} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
import { adminGet } from "@/lib/api/admin-gateway";
|
||||||
|
import { RenderQueueTable } from "@/components/admin/RenderQueueTable";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export type V2RenderJob = {
|
||||||
|
id: string;
|
||||||
|
saved_project_id: string;
|
||||||
|
user_id: string;
|
||||||
|
status: string;
|
||||||
|
step: string;
|
||||||
|
progress: number;
|
||||||
|
quality: string;
|
||||||
|
resolution: string;
|
||||||
|
frame_rate: number;
|
||||||
|
node_id: string | null;
|
||||||
|
error_message: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface V2RenderList {
|
||||||
|
items: V2RenderJob[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminRendersPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: { step?: string };
|
||||||
|
}) {
|
||||||
|
const step = searchParams.step ?? "";
|
||||||
|
const qs = step ? `?step=${step}&pageSize=50` : "?pageSize=50";
|
||||||
|
const data = await adminGet<V2RenderList>(`/v1/renders${qs}`);
|
||||||
|
const jobs = data?.items ?? [];
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
const t = await getTranslations("auto.appAdminRendersPage");
|
||||||
|
|
||||||
|
const steps = ["Queued", "Preparing", "Rendering", "Uploading", "Done", "Failed", "Cancelled"];
|
||||||
|
const stepLabels: Record<string, string> = {
|
||||||
|
Queued: t("stepQueued"),
|
||||||
|
Preparing: t("stepPreparing"),
|
||||||
|
Rendering: t("stepRendering"),
|
||||||
|
Uploading: t("stepUploading"),
|
||||||
|
Done: t("stepDone"),
|
||||||
|
Failed: t("stepFailed"),
|
||||||
|
Cancelled: t("stepCancelled"),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-white">{t("title")}</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">{t("totalJobs", { total })}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step filter tabs */}
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<a
|
||||||
|
href="/admin/renders"
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs font-medium transition-colors ${
|
||||||
|
!step
|
||||||
|
? "border-primary-500 bg-primary-600/20 text-white"
|
||||||
|
: "border-[#1e2235] text-gray-400 hover:text-white hover:border-[#2a3050]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("filterAll")}
|
||||||
|
</a>
|
||||||
|
{steps.map((s) => (
|
||||||
|
<a
|
||||||
|
key={s}
|
||||||
|
href={`/admin/renders?step=${s}`}
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs font-medium transition-colors ${
|
||||||
|
step === s
|
||||||
|
? "border-primary-500 bg-primary-600/20 text-white"
|
||||||
|
: "border-[#1e2235] text-gray-400 hover:text-white hover:border-[#2a3050]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{stepLabels[s] ?? s}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RenderQueueTable jobs={jobs} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AdminResource } from "@/components/admin/AdminResource";
|
||||||
|
import { slidesConfig } from "@/components/admin/admin-resources";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <AdminResource config={slidesConfig} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AdminResource } from "@/components/admin/AdminResource";
|
||||||
|
import { tagsConfig } from "@/components/admin/admin-resources";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <AdminResource config={tagsConfig} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AdminResource } from "@/components/admin/AdminResource";
|
||||||
|
import { usersConfig } from "@/components/admin/admin-resources";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <AdminResource config={usersConfig} />;
|
||||||
|
}
|
||||||
@@ -1,23 +1,28 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
import { AuthLoadingSpinner } from "@/components/auth/AuthLoadingSpinner";
|
import { AuthLoadingSpinner } from "@/components/auth/AuthLoadingSpinner";
|
||||||
import { AuthPageContent } from "@/components/auth/AuthPageContent";
|
import { AuthPageContent } from "@/components/auth/AuthPageContent";
|
||||||
import { createPageMetadata } from "@/lib/metadata";
|
import { createPageMetadata } from "@/lib/metadata";
|
||||||
|
|
||||||
export const metadata: Metadata = createPageMetadata({
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
title: "Sign In",
|
const t = await getTranslations("auto.appAuthPage");
|
||||||
description: "Sign in or create your CreatorStudio account.",
|
return createPageMetadata({
|
||||||
|
title: t("metaTitle"),
|
||||||
|
description: t("metaDescription"),
|
||||||
path: "/auth",
|
path: "/auth",
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default function AuthPage() {
|
export default async function AuthPage() {
|
||||||
|
const t = await getTranslations("auto.appAuthPage");
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-neutral-50">
|
<main className="min-h-screen bg-neutral-50">
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center py-20">
|
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center py-20">
|
||||||
<AuthLoadingSpinner label="Loading..." />
|
<AuthLoadingSpinner label={t("loading")} />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
import { SettingsBilling } from "@/components/dashboard/settings/SettingsBilling";
|
import { SettingsBilling } from "@/components/dashboard/settings/SettingsBilling";
|
||||||
import { SettingsNotifications } from "@/components/dashboard/settings/SettingsNotifications";
|
import { SettingsNotifications } from "@/components/dashboard/settings/SettingsNotifications";
|
||||||
import { SettingsProfile } from "@/components/dashboard/settings/SettingsProfile";
|
import { SettingsProfile } from "@/components/dashboard/settings/SettingsProfile";
|
||||||
import { SettingsSecurity } from "@/components/dashboard/settings/SettingsSecurity";
|
import { SettingsSecurity } from "@/components/dashboard/settings/SettingsSecurity";
|
||||||
|
import { getCurrentUser } from "@/lib/auth/session";
|
||||||
import { createPageMetadata } from "@/lib/metadata";
|
import { createPageMetadata } from "@/lib/metadata";
|
||||||
import { getUserProfile } from "@/lib/profiles";
|
import { getUserProfile } from "@/lib/profiles";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
|
||||||
|
|
||||||
export const metadata: Metadata = createPageMetadata({
|
export const metadata: Metadata = createPageMetadata({
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
@@ -17,16 +18,13 @@ export const metadata: Metadata = createPageMetadata({
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function DashboardSettingsPage() {
|
export default async function DashboardSettingsPage() {
|
||||||
const supabase = await createClient();
|
const t = await getTranslations("auto.appDashboardSettingsPage");
|
||||||
const {
|
// Auth is served by the V2 Identity service (JWT cookie), not Supabase.
|
||||||
data: { user },
|
const user = await getCurrentUser();
|
||||||
} = await supabase.auth.getUser();
|
|
||||||
|
|
||||||
const email = user?.email ?? "";
|
const email = user?.email ?? "";
|
||||||
const displayName =
|
const displayName =
|
||||||
typeof user?.user_metadata?.full_name === "string"
|
typeof user?.full_name === "string" ? user.full_name : null;
|
||||||
? user.user_metadata.full_name
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const profile = user ? await getUserProfile(user.id) : null;
|
const profile = user ? await getUserProfile(user.id) : null;
|
||||||
const plan = profile?.plan ?? "free";
|
const plan = profile?.plan ?? "free";
|
||||||
@@ -35,9 +33,9 @@ export default async function DashboardSettingsPage() {
|
|||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
{/* Page header */}
|
{/* Page header */}
|
||||||
<header className="border-b border-gray-100 bg-white px-6 py-4">
|
<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>
|
<h1 className="font-heading text-xl font-bold text-neutral-900">{t("title")}</h1>
|
||||||
<p className="mt-0.5 text-sm text-neutral-500">
|
<p className="mt-0.5 text-sm text-neutral-500">
|
||||||
Manage your account, security, and notification preferences.
|
{t("subtitle")}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -51,15 +49,15 @@ export default async function DashboardSettingsPage() {
|
|||||||
|
|
||||||
{/* Danger zone */}
|
{/* Danger zone */}
|
||||||
<div className="rounded-xl border border-red-100 bg-white p-6">
|
<div className="rounded-xl border border-red-100 bg-white p-6">
|
||||||
<h2 className="font-heading text-base font-semibold text-red-600">Danger zone</h2>
|
<h2 className="font-heading text-base font-semibold text-red-600">{t("dangerZoneTitle")}</h2>
|
||||||
<p className="mt-1 text-sm text-neutral-500">
|
<p className="mt-1 text-sm text-neutral-500">
|
||||||
Permanently delete your account and all your projects. This cannot be undone.
|
{t("dangerZoneDescription")}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="mt-4 rounded-lg border border-red-200 px-4 py-2 text-sm font-semibold text-red-600 transition-colors hover:bg-red-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2"
|
className="mt-4 rounded-lg border border-red-200 px-4 py-2 text-sm font-semibold text-red-600 transition-colors hover:bg-red-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
Delete account
|
{t("deleteAccount")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -10,6 +11,8 @@ interface ErrorPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ErrorPage({ error, reset }: ErrorPageProps) {
|
export default function ErrorPage({ error, reset }: ErrorPageProps) {
|
||||||
|
const t = useTranslations("auto.appError");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Surface to monitoring in production when configured
|
// Surface to monitoring in production when configured
|
||||||
}, [error]);
|
}, [error]);
|
||||||
@@ -17,17 +20,17 @@ export default function ErrorPage({ error, reset }: ErrorPageProps) {
|
|||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen flex-col items-center justify-center bg-white px-4 text-center">
|
<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">
|
<h1 className="font-heading text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||||
Something went wrong
|
{t("title")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-3 max-w-md text-sm text-neutral-600 sm:text-base">
|
<p className="mt-3 max-w-md text-sm text-neutral-600 sm:text-base">
|
||||||
An unexpected error occurred. Try reloading the page.
|
{t("description")}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="mt-8 bg-blue-600 hover:bg-blue-700"
|
className="mt-8 bg-blue-600 hover:bg-blue-700"
|
||||||
onClick={() => reset()}
|
onClick={() => reset()}
|
||||||
>
|
>
|
||||||
Reload page
|
{t("reloadButton")}
|
||||||
</Button>
|
</Button>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
import { ImageMakerCta } from "@/components/image-maker/ImageMakerCta";
|
import { ImageMakerCta } from "@/components/image-maker/ImageMakerCta";
|
||||||
import { ImageMakerFeatures } from "@/components/image-maker/ImageMakerFeatures";
|
import { ImageMakerFeatures } from "@/components/image-maker/ImageMakerFeatures";
|
||||||
@@ -7,12 +8,14 @@ import { ImageMakerHero } from "@/components/image-maker/ImageMakerHero";
|
|||||||
import { ImageMakerUseCases } from "@/components/image-maker/ImageMakerUseCases";
|
import { ImageMakerUseCases } from "@/components/image-maker/ImageMakerUseCases";
|
||||||
import { createPageMetadata } from "@/lib/metadata";
|
import { createPageMetadata } from "@/lib/metadata";
|
||||||
|
|
||||||
export const metadata: Metadata = createPageMetadata({
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
title: "AI Image Maker",
|
const t = await getTranslations("auto.appImageMakerPage");
|
||||||
description:
|
return createPageMetadata({
|
||||||
"Design professional visuals instantly with AI generation, templates, brand kits, and batch export.",
|
title: t("metaTitle"),
|
||||||
|
description: t("metaDescription"),
|
||||||
path: "/image-maker",
|
path: "/image-maker",
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default function ImageMakerPage() {
|
export default function ImageMakerPage() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { notFound } from "next/navigation";
|
|||||||
import { getMessages, getTranslations } from "next-intl/server";
|
import { getMessages, getTranslations } from "next-intl/server";
|
||||||
import { NextIntlClientProvider } from "next-intl";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
|
|
||||||
|
import { DirectionProvider } from "@/components/layout/DirectionProvider";
|
||||||
import { SiteChrome } from "@/components/layout/SiteChrome";
|
import { SiteChrome } from "@/components/layout/SiteChrome";
|
||||||
import { routing } from "@/i18n/routing";
|
import { routing } from "@/i18n/routing";
|
||||||
import type { Locale } from "@/i18n/routing";
|
import type { Locale } from "@/i18n/routing";
|
||||||
@@ -102,7 +103,6 @@ export default async function LocaleLayout({
|
|||||||
>
|
>
|
||||||
<head>
|
<head>
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
<link rel="preconnect" href="https://picsum.photos" />
|
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
className={`min-h-screen bg-white text-neutral-900 dark:bg-neutral-950 dark:text-neutral-50 ${
|
className={`min-h-screen bg-white text-neutral-900 dark:bg-neutral-950 dark:text-neutral-50 ${
|
||||||
@@ -110,7 +110,9 @@ export default async function LocaleLayout({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<NextIntlClientProvider messages={messages} locale={locale}>
|
<NextIntlClientProvider messages={messages} locale={locale}>
|
||||||
|
<DirectionProvider dir={isRtl ? "rtl" : "ltr"}>
|
||||||
<SiteChrome>{children}</SiteChrome>
|
<SiteChrome>{children}</SiteChrome>
|
||||||
|
</DirectionProvider>
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -10,17 +11,19 @@ export const metadata: Metadata = createPageMetadata({
|
|||||||
path: "/404",
|
path: "/404",
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function NotFoundPage() {
|
export default async function NotFoundPage() {
|
||||||
|
const t = await getTranslations("auto.appNotFound");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen flex-col items-center justify-center bg-white px-4 text-center">
|
<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">
|
<h1 className="font-heading text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||||||
Page not found
|
{t("title")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-3 max-w-md text-sm text-neutral-600 sm:text-base">
|
<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.
|
{t("description")}
|
||||||
</p>
|
</p>
|
||||||
<Button asChild className="mt-8 bg-blue-600 hover:bg-blue-700">
|
<Button asChild className="mt-8 bg-blue-600 hover:bg-blue-700">
|
||||||
<Link href="/">Go home</Link>
|
<Link href="/">{t("goHome")}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
import { Hero } from "@/components/sections/Hero";
|
import { Hero } from "@/components/sections/Hero";
|
||||||
import { HowItWorks } from "@/components/sections/HowItWorks";
|
import { HowItWorks } from "@/components/sections/HowItWorks";
|
||||||
@@ -10,12 +11,14 @@ import { Testimonials } from "@/components/sections/Testimonials";
|
|||||||
import { createPageMetadata } from "@/lib/metadata";
|
import { createPageMetadata } from "@/lib/metadata";
|
||||||
import { fetchProjects } from "@/lib/admin-api";
|
import { fetchProjects } from "@/lib/admin-api";
|
||||||
|
|
||||||
export const metadata: Metadata = createPageMetadata({
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
title: "Create Pro Videos & Images with AI",
|
const t = await getTranslations("auto.appPage");
|
||||||
description:
|
return createPageMetadata({
|
||||||
"FlatRender helps creators and brands make professional videos and images with AI templates, editors, and one-click export.",
|
title: t("metaTitle"),
|
||||||
|
description: t("metaDescription"),
|
||||||
path: "/",
|
path: "/",
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
// Fetch up to 8 published projects from the admin service.
|
// Fetch up to 8 published projects from the admin service.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
const ImageEditorLayout = dynamic(
|
const ImageEditorLayout = dynamic(
|
||||||
() =>
|
() =>
|
||||||
@@ -9,14 +10,19 @@ const ImageEditorLayout = dynamic(
|
|||||||
),
|
),
|
||||||
{
|
{
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => (
|
loading: () => <ImageEditorLoading />,
|
||||||
<div className="flex h-screen w-screen items-center justify-center bg-gray-950 text-sm text-gray-500">
|
|
||||||
Loading editor…
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function ImageEditorLoading() {
|
||||||
|
const t = useTranslations("auto.appStudioImageProjectIdPage");
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-screen items-center justify-center bg-gray-950 text-sm text-gray-500">
|
||||||
|
{t("loadingEditor")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface ImageStudioPageProps {
|
interface ImageStudioPageProps {
|
||||||
params: {
|
params: {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { ArrowLeft, Scissors } from "lucide-react";
|
import { ArrowLeft, Scissors } from "lucide-react";
|
||||||
|
|
||||||
import { TrimmerExportSection } from "@/components/trimmer/TrimmerExportSection";
|
import { TrimmerExportSection } from "@/components/trimmer/TrimmerExportSection";
|
||||||
@@ -22,6 +23,7 @@ import { parseFfmpegProgress } from "@/lib/trimmer-utils";
|
|||||||
const INITIAL_CROP: CropBox = { x: 0, y: 0, w: 320, h: 180 };
|
const INITIAL_CROP: CropBox = { x: 0, y: 0, w: 320, h: 180 };
|
||||||
|
|
||||||
export default function VideoTrimmerPage() {
|
export default function VideoTrimmerPage() {
|
||||||
|
const t = useTranslations("auto.appStudioTrimmerPage");
|
||||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
||||||
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
@@ -47,16 +49,14 @@ export default function VideoTrimmerPage() {
|
|||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setFfmpegError(
|
setFfmpegError(t("ffmpegLoadError"));
|
||||||
"Failed to load FFmpeg. Check your connection and try again."
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -130,7 +130,7 @@ export default function VideoTrimmerPage() {
|
|||||||
|
|
||||||
setOutputUrl(URL.createObjectURL(blob));
|
setOutputUrl(URL.createObjectURL(blob));
|
||||||
} catch {
|
} catch {
|
||||||
setFfmpegError("Processing failed. Try a shorter clip or different format.");
|
setFfmpegError(t("processingError"));
|
||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
@@ -144,6 +144,7 @@ export default function VideoTrimmerPage() {
|
|||||||
videoSize,
|
videoSize,
|
||||||
exportFormat,
|
exportFormat,
|
||||||
outputUrl,
|
outputUrl,
|
||||||
|
t,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -155,11 +156,11 @@ export default function VideoTrimmerPage() {
|
|||||||
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"
|
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 />
|
<ArrowLeft className="h-4 w-4" aria-hidden />
|
||||||
Back
|
{t("back")}
|
||||||
</Link>
|
</Link>
|
||||||
<Scissors className="h-5 w-5 text-blue-500" aria-hidden />
|
<Scissors className="h-5 w-5 text-blue-500" aria-hidden />
|
||||||
<h1 className="font-heading text-lg font-semibold text-white">
|
<h1 className="font-heading text-lg font-semibold text-white">
|
||||||
Video Trimmer & Cropper
|
{t("title")}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
const VideoStudioLayout = dynamic(
|
const VideoStudioLayout = dynamic(
|
||||||
() =>
|
() =>
|
||||||
@@ -9,14 +10,19 @@ const VideoStudioLayout = dynamic(
|
|||||||
),
|
),
|
||||||
{
|
{
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => (
|
loading: () => <VideoStudioLoading />,
|
||||||
<div className="flex h-screen w-screen items-center justify-center bg-gray-900 text-sm text-gray-500">
|
|
||||||
Loading studio…
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function VideoStudioLoading() {
|
||||||
|
const t = useTranslations("auto.appStudioVideoProjectIdPage");
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-screen items-center justify-center bg-gray-900 text-sm text-gray-500">
|
||||||
|
{t("loading")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface VideoStudioPageProps {
|
interface VideoStudioPageProps {
|
||||||
params: {
|
params: {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
import { VideoMakerCta } from "@/components/video-maker/VideoMakerCta";
|
import { VideoMakerCta } from "@/components/video-maker/VideoMakerCta";
|
||||||
import { VideoMakerFeatures } from "@/components/video-maker/VideoMakerFeatures";
|
import { VideoMakerFeatures } from "@/components/video-maker/VideoMakerFeatures";
|
||||||
@@ -7,12 +8,14 @@ import { VideoMakerTemplateCarousel } from "@/components/video-maker/VideoMakerT
|
|||||||
import { VideoMakerUseCases } from "@/components/video-maker/VideoMakerUseCases";
|
import { VideoMakerUseCases } from "@/components/video-maker/VideoMakerUseCases";
|
||||||
import { createPageMetadata } from "@/lib/metadata";
|
import { createPageMetadata } from "@/lib/metadata";
|
||||||
|
|
||||||
export const metadata: Metadata = createPageMetadata({
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
title: "AI Video Maker",
|
const t = await getTranslations("auto.appVideoMakerPage");
|
||||||
description:
|
return createPageMetadata({
|
||||||
"Create stunning videos in minutes with AI scripts, auto-subtitles, 500+ templates, and 1-click export.",
|
title: t("metaTitle"),
|
||||||
|
description: t("metaDescription"),
|
||||||
path: "/video-maker",
|
path: "/video-maker",
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default function VideoMakerPage() {
|
export default function VideoMakerPage() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Shared helper for admin action proxy routes.
|
||||||
|
* Validates the caller is an admin (checks is_admin in the JWT), then
|
||||||
|
* proxies the action to the V2 gateway.
|
||||||
|
*/
|
||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { gatewayUrl } from "@/lib/api/gateway";
|
||||||
|
import { getAccessToken } from "@/lib/auth/session";
|
||||||
|
import { decodeJwt } from "@/lib/auth/jwt";
|
||||||
|
|
||||||
|
export async function adminProxy(
|
||||||
|
_req: NextRequest,
|
||||||
|
gatewayPath: string,
|
||||||
|
method: string = "POST"
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
const token = await getAccessToken();
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick admin check on the server side before forwarding
|
||||||
|
const claims = decodeJwt(token);
|
||||||
|
const isAdmin =
|
||||||
|
String(claims?.is_admin) === "true" ||
|
||||||
|
claims?.is_admin === true ||
|
||||||
|
String(claims?.is_tenant_admin) === "true";
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(gatewayUrl(gatewayPath), {
|
||||||
|
method,
|
||||||
|
cache: "no-store",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => null) as { message?: string } | null;
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err?.message ?? "Gateway error" },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { gatewayUrl } from "@/lib/api/gateway";
|
||||||
|
import { getAccessToken } from "@/lib/auth/session";
|
||||||
|
import { decodeJwt } from "@/lib/auth/jwt";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forward an admin AI request to the V2 gateway, passing the request body through
|
||||||
|
* and returning the gateway's JSON response (status preserved). Admin-gated.
|
||||||
|
*/
|
||||||
|
export async function aiProxy(
|
||||||
|
req: NextRequest,
|
||||||
|
gatewayPath: string,
|
||||||
|
method: "GET" | "PUT" | "POST"
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
const token = await getAccessToken();
|
||||||
|
if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const claims = decodeJwt(token);
|
||||||
|
const isAdmin =
|
||||||
|
String(claims?.is_admin) === "true" ||
|
||||||
|
claims?.is_admin === true ||
|
||||||
|
String(claims?.is_tenant_admin) === "true";
|
||||||
|
if (!isAdmin) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
|
||||||
|
let body: string | undefined;
|
||||||
|
if (method !== "GET") {
|
||||||
|
const json = await req.json().catch(() => ({}));
|
||||||
|
body = JSON.stringify(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(gatewayUrl(gatewayPath), {
|
||||||
|
method,
|
||||||
|
cache: "no-store",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
if (!res.ok) {
|
||||||
|
const message =
|
||||||
|
(data && (data.error?.message ?? data.message)) || "Gateway error";
|
||||||
|
return NextResponse.json({ error: message }, { status: res.status });
|
||||||
|
}
|
||||||
|
return NextResponse.json(data ?? {}, { status: 200 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { type NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { aiProxy } from "../_aiProxy";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return aiProxy(req, "/v1/ai/seo-post", "POST");
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { type NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { aiProxy } from "../_aiProxy";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// Save a generated article as a blog post (content-svc BlogsController, admin-gated).
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return aiProxy(req, "/v1/blogs", "POST");
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { type NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { aiProxy } from "../_aiProxy";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
return aiProxy(req, "/v1/ai/settings", "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: NextRequest) {
|
||||||
|
return aiProxy(req, "/v1/ai/settings", "PUT");
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// "Drain" sets node status to Draining via PATCH so it finishes its current
|
||||||
|
// job but won't accept new ones.
|
||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { gatewayUrl } from "@/lib/api/gateway";
|
||||||
|
import { getAccessToken } from "@/lib/auth/session";
|
||||||
|
import { decodeJwt } from "@/lib/auth/jwt";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
interface Ctx { params: { nodeId: string } }
|
||||||
|
|
||||||
|
export async function POST(_req: NextRequest, { params }: Ctx) {
|
||||||
|
const token = await getAccessToken();
|
||||||
|
if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
const claims = decodeJwt(token);
|
||||||
|
const isAdmin = String(claims?.is_admin) === "true" || claims?.is_admin === true;
|
||||||
|
if (!isAdmin) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
|
||||||
|
const res = await fetch(gatewayUrl(`/v1/nodes/${params.nodeId}`), {
|
||||||
|
method: "PATCH",
|
||||||
|
cache: "no-store",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ status: "Draining" }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => null) as { message?: string } | null;
|
||||||
|
return NextResponse.json({ error: err?.message ?? "Gateway error" }, { status: res.status });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { type NextRequest } from "next/server";
|
||||||
|
import { adminProxy } from "@/app/api/admin/_adminProxy";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
interface Ctx { params: { nodeId: string } }
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest, { params }: Ctx) {
|
||||||
|
return adminProxy(req, `/v1/nodes/${params.nodeId}/release`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { type NextRequest } from "next/server";
|
||||||
|
import { adminProxy } from "@/app/api/admin/_adminProxy";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
interface Ctx { params: { jobId: string } }
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest, { params }: Ctx) {
|
||||||
|
return adminProxy(req, `/v1/renders/${params.jobId}/cancel`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { type NextRequest } from "next/server";
|
||||||
|
import { adminProxy } from "@/app/api/admin/_adminProxy";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
interface Ctx { params: { jobId: string } }
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest, { params }: Ctx) {
|
||||||
|
return adminProxy(req, `/v1/renders/${params.jobId}/retry`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { gatewayUrl } from "@/lib/api/gateway";
|
||||||
|
import { getAccessToken } from "@/lib/auth/session";
|
||||||
|
import { decodeJwt } from "@/lib/auth/jwt";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic admin proxy: forwards GET/POST/PUT/DELETE for any admin resource to the V2
|
||||||
|
* gateway under /v1/<path>, attaching the admin's bearer token. Admin-gated server-side.
|
||||||
|
*
|
||||||
|
* /api/admin/resource/categories → /v1/categories
|
||||||
|
* /api/admin/resource/categories/<id> → /v1/categories/<id>
|
||||||
|
* /api/admin/resource/users?page=1 → /v1/users?page=1
|
||||||
|
*
|
||||||
|
* Query string is preserved.
|
||||||
|
*/
|
||||||
|
async function forward(
|
||||||
|
req: NextRequest,
|
||||||
|
path: string[],
|
||||||
|
method: "GET" | "POST" | "PUT" | "DELETE"
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
const token = await getAccessToken();
|
||||||
|
if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const claims = decodeJwt(token);
|
||||||
|
const isAdmin =
|
||||||
|
String(claims?.is_admin) === "true" ||
|
||||||
|
claims?.is_admin === true ||
|
||||||
|
String(claims?.is_tenant_admin) === "true";
|
||||||
|
if (!isAdmin) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
|
||||||
|
const search = req.nextUrl.search ?? "";
|
||||||
|
// Trailing slash on the collection root avoids the gateway's 307 redirect.
|
||||||
|
const joined = path.join("/");
|
||||||
|
const gwPath = `/v1/${joined}${path.length === 1 && method === "GET" ? "/" : ""}${search}`;
|
||||||
|
|
||||||
|
let body: string | undefined;
|
||||||
|
if (method === "POST" || method === "PUT") {
|
||||||
|
const json = await req.json().catch(() => ({}));
|
||||||
|
body = JSON.stringify(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(gatewayUrl(gwPath), {
|
||||||
|
method,
|
||||||
|
cache: "no-store",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
redirect: "follow",
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
const data = text ? safeJson(text) : null;
|
||||||
|
if (!res.ok) {
|
||||||
|
const errObj = data?.error;
|
||||||
|
const message =
|
||||||
|
(typeof errObj === "object" && errObj?.message) ||
|
||||||
|
(typeof errObj === "string" ? errObj : undefined) ||
|
||||||
|
data?.message ||
|
||||||
|
"Gateway error";
|
||||||
|
return NextResponse.json({ error: message }, { status: res.status });
|
||||||
|
}
|
||||||
|
return NextResponse.json(data ?? {}, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GatewayResponse {
|
||||||
|
error?: { message?: string } | string;
|
||||||
|
message?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJson(t: string): GatewayResponse | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(t) as GatewayResponse;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest, ctx: { params: { path: string[] } }) {
|
||||||
|
return forward(req, ctx.params.path, "GET");
|
||||||
|
}
|
||||||
|
export async function POST(req: NextRequest, ctx: { params: { path: string[] } }) {
|
||||||
|
return forward(req, ctx.params.path, "POST");
|
||||||
|
}
|
||||||
|
export async function PUT(req: NextRequest, ctx: { params: { path: string[] } }) {
|
||||||
|
return forward(req, ctx.params.path, "PUT");
|
||||||
|
}
|
||||||
|
export async function DELETE(req: NextRequest, ctx: { params: { path: string[] } }) {
|
||||||
|
return forward(req, ctx.params.path, "DELETE");
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { gatewayFetch } from "@/lib/api/gateway";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/** POST /api/auth/password-reset-confirm — confirm reset with OTP + new password */
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
let body: unknown;
|
||||||
|
try { body = await request.json(); } catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const { email, otp, new_password } = body as { email?: string; otp?: string; new_password?: string };
|
||||||
|
if (!email || !otp || !new_password) {
|
||||||
|
return NextResponse.json({ error: "email, otp, and new_password are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await gatewayFetch("/v1/auth/password/reset/confirm", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ email, otp, new_password }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => null) as { message?: string } | null;
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: data?.message ?? "Invalid or expired code" },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { gatewayFetch } from "@/lib/api/gateway";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/** POST /api/auth/password-reset — request a password reset OTP email */
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
let body: unknown;
|
||||||
|
try { body = await request.json(); } catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const { email } = body as { email?: string };
|
||||||
|
if (!email) return NextResponse.json({ error: "email required" }, { status: 400 });
|
||||||
|
|
||||||
|
const res = await gatewayFetch("/v1/auth/password/reset/request", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
});
|
||||||
|
// Always return 200 to avoid user enumeration
|
||||||
|
if (!res.ok && res.status !== 404) {
|
||||||
|
return NextResponse.json({ error: "Request failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { gatewayUrl } from "@/lib/api/gateway";
|
||||||
|
import { getAccessToken } from "@/lib/auth/session";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
/** POST /api/billing/cancel — cancel the current active plan. */
|
||||||
|
export async function POST() {
|
||||||
|
const token = await getAccessToken();
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(gatewayUrl("/v1/users/me/plan/cancel"), {
|
||||||
|
method: "POST",
|
||||||
|
cache: "no-store",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err?.error ?? "Failed to cancel plan" },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/billing/portal
|
||||||
|
*
|
||||||
|
* In the Stripe era this redirected to a Stripe-hosted portal.
|
||||||
|
* With V2 (ZarinPal / SnapPay) the portal is in-app — redirect to the
|
||||||
|
* billing tab in settings.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
redirect("/dashboard/settings?tab=billing");
|
||||||
|
}
|
||||||
@@ -1,33 +1,49 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { BillingPeriod } from "@/components/sections/pricing-data";
|
import { gatewayUrl } from "@/lib/api/gateway";
|
||||||
import { getStripePriceId, isPaidPlanId } from "@/lib/plans";
|
import { getAccessToken } from "@/lib/auth/session";
|
||||||
import { getStripe } from "@/lib/stripe";
|
|
||||||
import { createClient } from "@/lib/supabase/server";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const checkoutSchema = z.object({
|
const checkoutSchema = z.object({
|
||||||
plan: z.enum(["pro", "business"]),
|
plan: z.enum(["pro", "business"]),
|
||||||
billing: z.enum(["monthly", "annual"]),
|
billing: z.enum(["monthly", "annual"]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
interface V2Plan {
|
||||||
try {
|
id: string;
|
||||||
const supabase = await createClient();
|
code: string;
|
||||||
const {
|
name: string;
|
||||||
data: { user },
|
billing_period: string;
|
||||||
} = await supabase.auth.getUser();
|
}
|
||||||
|
|
||||||
if (!user?.email) {
|
/**
|
||||||
|
* Start a plan purchase through the V2 Identity/payments flow.
|
||||||
|
*
|
||||||
|
* Replaces the direct Stripe Checkout + Supabase profile loop. We resolve the
|
||||||
|
* requested plan ("pro"/"business" × "monthly"/"annual") to a plan GUID via
|
||||||
|
* `/v1/plans` (codes follow the `pro_monthly` / `business_annual` convention),
|
||||||
|
* then POST `/v1/users/me/plan/purchase`. The payments service owns the gateway
|
||||||
|
* (ZarinPal/Stripe) and returns a redirect URL we hand back to the client.
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const token = await getAccessToken();
|
||||||
|
if (!token) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "You must be signed in to checkout." },
|
{ error: "You must be signed in to checkout." },
|
||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const body: unknown = await request.json();
|
let body: unknown;
|
||||||
const parsed = checkoutSchema.safeParse(body);
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = checkoutSchema.safeParse(body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Invalid plan or billing period." },
|
{ error: "Invalid plan or billing period." },
|
||||||
@@ -36,55 +52,64 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { plan, billing } = parsed.data;
|
const { plan, billing } = parsed.data;
|
||||||
|
const targetCode = `${plan}_${billing === "annual" ? "annual" : "monthly"}`;
|
||||||
|
|
||||||
if (!isPaidPlanId(plan)) {
|
// Resolve plan code → GUID. Plans are public, but pass the token so tenant
|
||||||
return NextResponse.json({ error: "Invalid plan." }, { status: 400 });
|
// overrides resolve correctly.
|
||||||
}
|
const plansRes = await fetch(gatewayUrl("/v1/plans"), {
|
||||||
|
cache: "no-store",
|
||||||
const priceId = getStripePriceId(plan, billing as BillingPeriod);
|
headers: { Accept: "application/json", Authorization: `Bearer ${token}` },
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
const plansJson = plansRes.ok
|
||||||
|
? ((await plansRes.json().catch(() => null)) as { data?: V2Plan[] } | null)
|
||||||
|
: null;
|
||||||
|
const match = plansJson?.data?.find(
|
||||||
|
(p) => p.code?.toLowerCase() === targetCode
|
||||||
|
);
|
||||||
|
|
||||||
if (!session.url) {
|
if (!match) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Failed to create checkout session." },
|
{
|
||||||
{ status: 500 }
|
error:
|
||||||
|
"This plan is not available yet. Please try again later or contact support.",
|
||||||
|
code: "PLAN_NOT_AVAILABLE",
|
||||||
|
},
|
||||||
|
{ status: 503 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ url: session.url });
|
const purchaseRes = await fetch(gatewayUrl("/v1/users/me/plan/purchase"), {
|
||||||
} catch (error) {
|
method: "POST",
|
||||||
const message =
|
cache: "no-store",
|
||||||
error instanceof Error ? error.message : "Checkout failed.";
|
headers: {
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ plan_id: match.id }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!purchaseRes.ok) {
|
||||||
|
const err = (await purchaseRes.json().catch(() => null)) as {
|
||||||
|
message?: string;
|
||||||
|
} | null;
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err?.message ?? "Failed to start checkout." },
|
||||||
|
{ status: purchaseRes.status }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = (await purchaseRes.json().catch(() => null)) as {
|
||||||
|
redirect_url?: string;
|
||||||
|
payment_id?: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
if (!result?.redirect_url) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Payment gateway did not return a redirect URL." },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ url: result.redirect_url });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { type NextRequest } from "next/server";
|
||||||
|
|
||||||
|
// Deterministic, dependency-free placeholder image generator. Returns an SVG gradient
|
||||||
|
// derived from the `seed` query param so each placeholder is stable and distinct.
|
||||||
|
// Same-origin and offline — replaces external picsum.photos in restricted networks.
|
||||||
|
|
||||||
|
function hashString(s: string): number {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
h = (h << 5) - h + s.charCodeAt(i);
|
||||||
|
h |= 0; // force 32-bit
|
||||||
|
}
|
||||||
|
return Math.abs(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampDim(raw: string, fallback: number): number {
|
||||||
|
const n = parseInt(raw, 10);
|
||||||
|
if (!Number.isFinite(n)) return fallback;
|
||||||
|
return Math.min(2000, Math.max(1, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
context: { params: { width: string; height: string } }
|
||||||
|
) {
|
||||||
|
const { width, height } = context.params;
|
||||||
|
const w = clampDim(width, 400);
|
||||||
|
const h = clampDim(height, 300);
|
||||||
|
const seed = req.nextUrl.searchParams.get("seed") ?? "flatrender";
|
||||||
|
|
||||||
|
const hash = hashString(seed);
|
||||||
|
const hue1 = hash % 360;
|
||||||
|
const hue2 = (hue1 + 40 + (hash % 80)) % 360;
|
||||||
|
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" preserveAspectRatio="xMidYMid slice">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="hsl(${hue1} 64% 58%)"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(${hue2} 58% 42%)"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="${w}" height="${h}" fill="url(#g)"/>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
return new Response(svg, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "image/svg+xml",
|
||||||
|
"Cache-Control": "public, max-age=31536000, immutable",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { getAccessToken } from "@/lib/auth/session";
|
||||||
import { getRenderJob } from "@/lib/render-jobs";
|
import { getRenderJob } from "@/lib/render-jobs";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@@ -15,7 +16,12 @@ export async function GET(_request: Request, context: RouteContext) {
|
|||||||
return NextResponse.json({ error: "jobId required" }, { status: 400 });
|
return NextResponse.json({ error: "jobId required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const job = await getRenderJob(jobId);
|
const token = await getAccessToken();
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = await getRenderJob(jobId, token);
|
||||||
if (!job) {
|
if (!job) {
|
||||||
return NextResponse.json({ error: "Job not found" }, { status: 404 });
|
return NextResponse.json({ error: "Job not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
@@ -26,5 +32,6 @@ export async function GET(_request: Request, context: RouteContext) {
|
|||||||
outputUrl: job.output_url,
|
outputUrl: job.output_url,
|
||||||
progressMessage: job.progress_message,
|
progressMessage: job.progress_message,
|
||||||
errorMessage: job.error_message,
|
errorMessage: job.error_message,
|
||||||
|
previewB64: job.preview_b64 ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { getAccessToken } from "@/lib/auth/session";
|
||||||
|
import { createRenderJob } from "@/lib/render-jobs";
|
||||||
import { renderRequestSchema } from "@/lib/render-schemas";
|
import { renderRequestSchema } from "@/lib/render-schemas";
|
||||||
import { createRenderJob, triggerRenderWorker } from "@/lib/render-jobs";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
const token = await getAccessToken();
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
let body: unknown;
|
let body: unknown;
|
||||||
try {
|
try {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
@@ -21,12 +27,10 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await createRenderJob(parsed.data);
|
const result = await createRenderJob(parsed.data, token);
|
||||||
if ("error" in result) {
|
if ("error" in result) {
|
||||||
return NextResponse.json({ error: result.error }, { status: 500 });
|
return NextResponse.json({ error: result.error }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await triggerRenderWorker(result.jobId);
|
|
||||||
|
|
||||||
return NextResponse.json({ jobId: result.jobId });
|
return NextResponse.json({ jobId: result.jobId });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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,244 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface FieldDef {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type?: "text" | "textarea" | "number" | "checkbox" | "select";
|
||||||
|
options?: { value: string; label: string }[];
|
||||||
|
required?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
defaultValue?: string | number | boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColumnDef {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
render?: (row: Record<string, unknown>) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceConfig {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
basePath: string; // e.g. "categories"
|
||||||
|
idKey?: string; // default "id"
|
||||||
|
listKey?: string; // wrap key, e.g. "items"; omit if response is a bare array
|
||||||
|
columns: ColumnDef[];
|
||||||
|
fields?: FieldDef[];
|
||||||
|
canCreate?: boolean;
|
||||||
|
canEdit?: boolean;
|
||||||
|
canDelete?: boolean;
|
||||||
|
rowActions?: (row: Record<string, unknown>, reload: () => void) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const card = "rounded-xl border border-[#1e2235] bg-[#0f1120]";
|
||||||
|
const btn = "rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:opacity-50";
|
||||||
|
const btnGhost = "rounded-lg border border-[#262b40] px-3 py-1.5 text-xs text-gray-300 hover:bg-[#161a2e]";
|
||||||
|
const inputCls = "w-full rounded-lg border border-[#262b40] bg-[#0c0e1a] px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500";
|
||||||
|
|
||||||
|
export function AdminResource({ config }: { config: ResourceConfig }) {
|
||||||
|
const idKey = config.idKey ?? "id";
|
||||||
|
const [rows, setRows] = useState<Record<string, unknown>[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [editing, setEditing] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [form, setForm] = useState<Record<string, unknown>>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const url = (suffix = "") => `/api/admin/resource/${config.basePath}${suffix}`;
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(url(), { cache: "no-store" });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data?.error ?? "Failed to load");
|
||||||
|
const list = config.listKey ? data?.[config.listKey] : data;
|
||||||
|
setRows(Array.isArray(list) ? list : (data?.items ?? []));
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to load");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [config.basePath, config.listKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reload();
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
const init: Record<string, unknown> = {};
|
||||||
|
config.fields?.forEach((f) => (init[f.key] = f.defaultValue ?? (f.type === "checkbox" ? false : "")));
|
||||||
|
setForm(init);
|
||||||
|
setCreating(true);
|
||||||
|
setEditing(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (row: Record<string, unknown>) => {
|
||||||
|
const init: Record<string, unknown> = {};
|
||||||
|
config.fields?.forEach((f) => (init[f.key] = row[f.key] ?? (f.type === "checkbox" ? false : "")));
|
||||||
|
setForm(init);
|
||||||
|
setEditing(row);
|
||||||
|
setCreating(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeForm = () => {
|
||||||
|
setCreating(false);
|
||||||
|
setEditing(null);
|
||||||
|
setForm({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const isEdit = !!editing;
|
||||||
|
const res = await fetch(isEdit ? url(`/${editing![idKey]}`) : url(), {
|
||||||
|
method: isEdit ? "PUT" : "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(form),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
if (!res.ok) throw new Error(data?.error ?? "Save failed");
|
||||||
|
closeForm();
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Save failed");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (row: Record<string, unknown>) => {
|
||||||
|
if (!confirm(`Delete this ${config.title.replace(/s$/, "").toLowerCase()}?`)) return;
|
||||||
|
const res = await fetch(url(`/${row[idKey]}`), { method: "DELETE" });
|
||||||
|
if (res.ok) reload();
|
||||||
|
else {
|
||||||
|
const d = await res.json().catch(() => null);
|
||||||
|
setError(d?.error ?? "Delete failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-white">{config.title}</h1>
|
||||||
|
{config.description && <p className="mt-1 text-sm text-gray-400">{config.description}</p>}
|
||||||
|
</div>
|
||||||
|
{config.canCreate && config.fields && (
|
||||||
|
<button className={btn} onClick={openCreate}>+ New</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-300">{error}</p>}
|
||||||
|
|
||||||
|
<div className={`${card} overflow-hidden`}>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-[#1e2235] text-left text-xs text-gray-500">
|
||||||
|
{config.columns.map((c) => (
|
||||||
|
<th key={c.key} className="px-4 py-3 font-medium">{c.label}</th>
|
||||||
|
))}
|
||||||
|
{(config.canEdit || config.canDelete || config.rowActions) && (
|
||||||
|
<th className="px-4 py-3 text-right font-medium">Actions</th>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr><td className="px-4 py-8 text-center text-gray-500" colSpan={99}>Loading…</td></tr>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<tr><td className="px-4 py-8 text-center text-gray-500" colSpan={99}>No records.</td></tr>
|
||||||
|
) : (
|
||||||
|
rows.map((row, i) => (
|
||||||
|
<tr key={String(row[idKey] ?? i)} className="border-b border-[#161a2e] hover:bg-[#12152a]">
|
||||||
|
{config.columns.map((c) => (
|
||||||
|
<td key={c.key} className="px-4 py-3 text-gray-200">
|
||||||
|
{c.render ? c.render(row) : formatCell(row[c.key])}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
{(config.canEdit || config.canDelete || config.rowActions) && (
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{config.rowActions?.(row, reload)}
|
||||||
|
{config.canEdit && config.fields && (
|
||||||
|
<button className={btnGhost} onClick={() => openEdit(row)}>Edit</button>
|
||||||
|
)}
|
||||||
|
{config.canDelete && (
|
||||||
|
<button
|
||||||
|
className="rounded-lg border border-red-500/30 px-3 py-1.5 text-xs text-red-300 hover:bg-red-500/10"
|
||||||
|
onClick={() => remove(row)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(creating || editing) && config.fields && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={closeForm}>
|
||||||
|
<div className={`${card} w-full max-w-lg p-5`} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h2 className="text-sm font-semibold text-white">
|
||||||
|
{editing ? "Edit" : "New"} {config.title.replace(/s$/, "")}
|
||||||
|
</h2>
|
||||||
|
<div className="mt-4 grid max-h-[60vh] gap-3 overflow-y-auto pr-1">
|
||||||
|
{config.fields.map((f) => (
|
||||||
|
<div key={f.key}>
|
||||||
|
{f.type !== "checkbox" && (
|
||||||
|
<label className="mb-1 block text-xs font-medium text-gray-400">
|
||||||
|
{f.label}{f.required && <span className="text-red-400"> *</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{f.type === "textarea" ? (
|
||||||
|
<textarea className={`${inputCls} min-h-[80px]`} placeholder={f.placeholder}
|
||||||
|
value={String(form[f.key] ?? "")} onChange={(e) => setForm({ ...form, [f.key]: e.target.value })} />
|
||||||
|
) : f.type === "select" ? (
|
||||||
|
<select className={inputCls} value={String(form[f.key] ?? "")}
|
||||||
|
onChange={(e) => setForm({ ...form, [f.key]: e.target.value })}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{f.options?.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
) : f.type === "checkbox" ? (
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-300">
|
||||||
|
<input type="checkbox" checked={!!form[f.key]} onChange={(e) => setForm({ ...form, [f.key]: e.target.checked })} />
|
||||||
|
{f.label}
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<input type={f.type === "number" ? "number" : "text"} className={inputCls} placeholder={f.placeholder}
|
||||||
|
value={String(form[f.key] ?? "")}
|
||||||
|
onChange={(e) => setForm({ ...form, [f.key]: f.type === "number" ? Number(e.target.value) : e.target.value })} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 flex items-center justify-end gap-2">
|
||||||
|
<button className={btnGhost} onClick={closeForm}>Cancel</button>
|
||||||
|
<button className={btn} onClick={submit} disabled={saving}>{saving ? "Saving…" : "Save"}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCell(v: unknown): ReactNode {
|
||||||
|
if (v === null || v === undefined || v === "") return <span className="text-gray-600">—</span>;
|
||||||
|
if (typeof v === "boolean") return v ? "✓" : "✗";
|
||||||
|
if (Array.isArray(v)) return v.join(", ");
|
||||||
|
if (typeof v === "object") return JSON.stringify(v).slice(0, 40);
|
||||||
|
const s = String(v);
|
||||||
|
return s.length > 60 ? s.slice(0, 60) + "…" : s;
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
interface AiSettings {
|
||||||
|
provider: string;
|
||||||
|
base_url: string;
|
||||||
|
model: string;
|
||||||
|
enabled: boolean;
|
||||||
|
has_api_key: boolean;
|
||||||
|
api_key_masked: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SeoPost {
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
meta_title: string;
|
||||||
|
meta_description: string;
|
||||||
|
keywords: string[];
|
||||||
|
short_description: string;
|
||||||
|
content_html: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const card = "rounded-xl border border-[#1e2235] bg-[#0f1120] p-5";
|
||||||
|
const label = "block text-xs font-medium text-gray-400 mb-1";
|
||||||
|
const input =
|
||||||
|
"w-full rounded-lg border border-[#262b40] bg-[#0c0e1a] px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500";
|
||||||
|
const btn =
|
||||||
|
"rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-50";
|
||||||
|
const btnGhost =
|
||||||
|
"rounded-lg border border-[#262b40] px-4 py-2 text-sm text-gray-300 hover:bg-[#161a2e] disabled:opacity-50";
|
||||||
|
|
||||||
|
export function AiContentStudio() {
|
||||||
|
const t = useTranslations("auto.adminAi");
|
||||||
|
|
||||||
|
const [settings, setSettings] = useState<AiSettings | null>(null);
|
||||||
|
const [apiKey, setApiKey] = useState("");
|
||||||
|
const [savingSettings, setSavingSettings] = useState(false);
|
||||||
|
const [settingsMsg, setSettingsMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Generation form
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [type, setType] = useState("");
|
||||||
|
const [tags, setTags] = useState("");
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [audience, setAudience] = useState("");
|
||||||
|
const [locale, setLocale] = useState("fa");
|
||||||
|
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const [genError, setGenError] = useState<string | null>(null);
|
||||||
|
const [post, setPost] = useState<SeoPost | null>(null);
|
||||||
|
const [publishNow, setPublishNow] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveMsg, setSaveMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadSettings = useCallback(async () => {
|
||||||
|
const res = await fetch("/api/admin/ai/settings", { cache: "no-store" });
|
||||||
|
if (res.ok) setSettings(await res.json());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSettings();
|
||||||
|
}, [loadSettings]);
|
||||||
|
|
||||||
|
const saveSettings = async () => {
|
||||||
|
if (!settings) return;
|
||||||
|
setSavingSettings(true);
|
||||||
|
setSettingsMsg(null);
|
||||||
|
const res = await fetch("/api/admin/ai/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
provider: settings.provider,
|
||||||
|
base_url: settings.base_url,
|
||||||
|
model: settings.model,
|
||||||
|
enabled: settings.enabled,
|
||||||
|
api_key: apiKey.trim() === "" ? null : apiKey.trim(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setSettings(await res.json());
|
||||||
|
setApiKey("");
|
||||||
|
setSettingsMsg(t("settingsSaved"));
|
||||||
|
} else {
|
||||||
|
setSettingsMsg(t("settingsError"));
|
||||||
|
}
|
||||||
|
setSavingSettings(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const generate = async () => {
|
||||||
|
setGenerating(true);
|
||||||
|
setGenError(null);
|
||||||
|
setSaveMsg(null);
|
||||||
|
const res = await fetch("/api/admin/ai/generate", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
description,
|
||||||
|
title: title || null,
|
||||||
|
type: type || null,
|
||||||
|
tags: tags ? tags.split(",").map((s) => s.trim()).filter(Boolean) : null,
|
||||||
|
keyword: keyword || null,
|
||||||
|
audience: audience || null,
|
||||||
|
locale,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
if (res.ok) setPost(data);
|
||||||
|
else setGenError(data?.error ?? t("generateError"));
|
||||||
|
setGenerating(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveAsBlog = async () => {
|
||||||
|
if (!post) return;
|
||||||
|
setSaving(true);
|
||||||
|
setSaveMsg(null);
|
||||||
|
const res = await fetch("/api/admin/ai/save", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
slug: post.slug,
|
||||||
|
title: post.title,
|
||||||
|
short_description: post.short_description,
|
||||||
|
content: post.content_html,
|
||||||
|
meta_title: post.meta_title,
|
||||||
|
meta_description: post.meta_description,
|
||||||
|
meta_keywords: post.keywords.join(", "),
|
||||||
|
include_in_site_map: true,
|
||||||
|
is_published: publishNow,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setSaveMsg(res.ok ? t("savedAsBlog") : t("saveError"));
|
||||||
|
setSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPostField = (k: keyof SeoPost, v: string) =>
|
||||||
|
setPost((p) => (p ? { ...p, [k]: v } : p));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-white">{t("pageTitle")}</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-400">{t("pageDesc")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Settings ─────────────────────────────────────────── */}
|
||||||
|
<section className={card}>
|
||||||
|
<h2 className="text-sm font-semibold text-white">{t("settingsTitle")}</h2>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">{t("settingsDesc")}</p>
|
||||||
|
{settings && (
|
||||||
|
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className={label}>{t("apiKeyLabel")}</label>
|
||||||
|
<input
|
||||||
|
className={input}
|
||||||
|
type="password"
|
||||||
|
placeholder={settings.api_key_masked ?? t("apiKeyPlaceholder")}
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-[11px] text-gray-500">
|
||||||
|
{settings.has_api_key ? `✓ ${t("keyConfigured")}` : t("noKey")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={label}>{t("baseUrlLabel")}</label>
|
||||||
|
<input
|
||||||
|
className={input}
|
||||||
|
value={settings.base_url}
|
||||||
|
onChange={(e) => setSettings({ ...settings, base_url: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={label}>{t("modelLabel")}</label>
|
||||||
|
<input
|
||||||
|
className={input}
|
||||||
|
value={settings.model}
|
||||||
|
onChange={(e) => setSettings({ ...settings, model: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.enabled}
|
||||||
|
onChange={(e) => setSettings({ ...settings, enabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
{t("enabledLabel")}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3 sm:col-span-2">
|
||||||
|
<button className={btn} onClick={saveSettings} disabled={savingSettings}>
|
||||||
|
{savingSettings ? t("saving") : t("saveSettings")}
|
||||||
|
</button>
|
||||||
|
{settingsMsg && <span className="text-xs text-gray-400">{settingsMsg}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Generator ────────────────────────────────────────── */}
|
||||||
|
<section className={card}>
|
||||||
|
<h2 className="text-sm font-semibold text-white">{t("generateTitle")}</h2>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">{t("generateDesc")}</p>
|
||||||
|
{settings && !settings.enabled && (
|
||||||
|
<p className="mt-3 rounded-lg bg-amber-500/10 px-3 py-2 text-xs text-amber-300">
|
||||||
|
{t("mustConfigure")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-4 grid gap-4">
|
||||||
|
<div>
|
||||||
|
<label className={label}>{t("descriptionLabel")}</label>
|
||||||
|
<textarea
|
||||||
|
className={`${input} min-h-[100px]`}
|
||||||
|
placeholder={t("descriptionPlaceholder")}
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className={label}>{t("titleLabel")}</label>
|
||||||
|
<input className={input} value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={label}>{t("typeLabel")}</label>
|
||||||
|
<input className={input} placeholder={t("typePlaceholder")} value={type} onChange={(e) => setType(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={label}>{t("keywordLabel")}</label>
|
||||||
|
<input className={input} value={keyword} onChange={(e) => setKeyword(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={label}>{t("audienceLabel")}</label>
|
||||||
|
<input className={input} value={audience} onChange={(e) => setAudience(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={label}>{t("tagsLabel")}</label>
|
||||||
|
<input className={input} value={tags} onChange={(e) => setTags(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={label}>{t("localeLabel")}</label>
|
||||||
|
<select className={input} value={locale} onChange={(e) => setLocale(e.target.value)}>
|
||||||
|
<option value="fa">{t("localeFa")}</option>
|
||||||
|
<option value="en">{t("localeEn")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button className={btn} onClick={generate} disabled={generating || !description.trim()}>
|
||||||
|
{generating ? t("generating") : t("generate")}
|
||||||
|
</button>
|
||||||
|
{genError && <span className="text-xs text-red-400">{genError}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Result ───────────────────────────────────────────── */}
|
||||||
|
{post && (
|
||||||
|
<section className={card}>
|
||||||
|
<h2 className="text-sm font-semibold text-white">{t("resultTitle")}</h2>
|
||||||
|
<div className="mt-4 grid gap-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className={label}>{t("fTitle")}</label>
|
||||||
|
<input className={input} value={post.title} onChange={(e) => setPostField("title", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={label}>{t("fSlug")}</label>
|
||||||
|
<input className={input} value={post.slug} onChange={(e) => setPostField("slug", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={label}>{t("fMetaTitle")}</label>
|
||||||
|
<input className={input} value={post.meta_title} onChange={(e) => setPostField("meta_title", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={label}>{t("fKeywords")}</label>
|
||||||
|
<input className={input} value={post.keywords.join(", ")} onChange={(e) => setPost({ ...post, keywords: e.target.value.split(",").map((s) => s.trim()).filter(Boolean) })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={label}>{t("fMetaDesc")}</label>
|
||||||
|
<textarea className={`${input} min-h-[60px]`} value={post.meta_description} onChange={(e) => setPostField("meta_description", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={label}>{t("fShortDesc")}</label>
|
||||||
|
<textarea className={`${input} min-h-[60px]`} value={post.short_description} onChange={(e) => setPostField("short_description", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={label}>{t("fContent")}</label>
|
||||||
|
<textarea className={`${input} min-h-[200px] font-mono text-xs`} value={post.content_html} onChange={(e) => setPostField("content_html", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={label}>{t("preview")}</label>
|
||||||
|
<div
|
||||||
|
className="prose prose-invert max-w-none rounded-lg border border-[#262b40] bg-white p-4 text-black"
|
||||||
|
dangerouslySetInnerHTML={{ __html: post.content_html }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-300">
|
||||||
|
<input type="checkbox" checked={publishNow} onChange={(e) => setPublishNow(e.target.checked)} />
|
||||||
|
{t("publishNow")}
|
||||||
|
</label>
|
||||||
|
<button className={btnGhost} onClick={saveAsBlog} disabled={saving}>
|
||||||
|
{saving ? t("saving") : t("saveAsBlog")}
|
||||||
|
</button>
|
||||||
|
{saveMsg && <span className="text-xs text-gray-400">{saveMsg}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { apiFetch } from "@/lib/api/fetch";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
interface V2Node {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: "Online" | "Busy" | "Offline" | "Draining";
|
||||||
|
last_heartbeat: string;
|
||||||
|
active_job_id: string | null;
|
||||||
|
slots_total: number;
|
||||||
|
slots_used: number;
|
||||||
|
version: string | null;
|
||||||
|
tags: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<V2Node["status"], string> = {
|
||||||
|
Online: "bg-emerald-500/20 text-emerald-300 border-emerald-500/30",
|
||||||
|
Busy: "bg-blue-500/20 text-blue-300 border-blue-500/30",
|
||||||
|
Offline: "bg-gray-500/20 text-gray-400 border-gray-500/30",
|
||||||
|
Draining: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
|
||||||
|
};
|
||||||
|
|
||||||
|
function heartbeatAge(iso: string): string {
|
||||||
|
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
||||||
|
if (diff < 60) return `${diff}s ago`;
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NodesTable({ nodes }: { nodes: V2Node[] }) {
|
||||||
|
const t = useTranslations("auto.componentsAdminNodesTable");
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const action = async (nodeId: string, endpoint: string) => {
|
||||||
|
setLoading((p) => ({ ...p, [nodeId]: true }));
|
||||||
|
try {
|
||||||
|
await apiFetch(`/api/admin/nodes/${nodeId}/${endpoint}`, { method: "POST" });
|
||||||
|
router.refresh();
|
||||||
|
} finally {
|
||||||
|
setLoading((p) => ({ ...p, [nodeId]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-[#1e2235] bg-[#0f1120] px-6 py-16 text-center text-sm text-gray-500">
|
||||||
|
{t("emptyState")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-xl border border-[#1e2235]">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-[#1e2235] bg-[#0f1120] text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
<th className="px-4 py-3">{t("colNode")}</th>
|
||||||
|
<th className="px-4 py-3">{t("colStatus")}</th>
|
||||||
|
<th className="px-4 py-3">{t("colSlots")}</th>
|
||||||
|
<th className="px-4 py-3">{t("colHeartbeat")}</th>
|
||||||
|
<th className="px-4 py-3">{t("colActiveJob")}</th>
|
||||||
|
<th className="px-4 py-3">{t("colTags")}</th>
|
||||||
|
<th className="px-4 py-3">{t("colActions")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-[#1e2235] bg-[#0c0e1a]">
|
||||||
|
{nodes.map((node) => (
|
||||||
|
<tr key={node.id} className="hover:bg-[#0f1120]/60 transition-colors">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="font-medium text-white">{node.name}</div>
|
||||||
|
<div className="text-[11px] text-gray-600 font-mono mt-0.5">{node.id.slice(0, 8)}…</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium ${STATUS_COLORS[node.status]}`}>
|
||||||
|
{node.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 tabular-nums text-gray-300">
|
||||||
|
{node.slots_used} / {node.slots_total}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-400">
|
||||||
|
{heartbeatAge(node.last_heartbeat)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-[11px] text-gray-500">
|
||||||
|
{node.active_job_id ? node.active_job_id.slice(0, 12) + "…" : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(node.tags ?? []).map((t) => (
|
||||||
|
<span key={t} className="rounded bg-[#1e2235] px-1.5 py-0.5 text-[10px] text-gray-400">
|
||||||
|
{t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => action(node.id, "drain")}
|
||||||
|
disabled={loading[node.id] || node.status === "Offline"}
|
||||||
|
className="rounded px-2.5 py-1 text-xs text-yellow-300 border border-yellow-500/30 hover:bg-yellow-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{t("actionDrain")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => action(node.id, "release")}
|
||||||
|
disabled={loading[node.id]}
|
||||||
|
className="rounded px-2.5 py-1 text-xs text-red-300 border border-red-500/30 hover:bg-red-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{t("actionRelease")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { apiFetch } from "@/lib/api/fetch";
|
||||||
|
import type { V2RenderJob } from "@/app/[locale]/admin/renders/page";
|
||||||
|
|
||||||
|
const STEP_COLORS: Record<string, string> = {
|
||||||
|
Queued: "bg-gray-500/20 text-gray-400 border-gray-500/30",
|
||||||
|
Preparing: "bg-blue-500/20 text-blue-300 border-blue-500/30",
|
||||||
|
TemplateCache:"bg-blue-500/20 text-blue-300 border-blue-500/30",
|
||||||
|
JsxGen: "bg-blue-500/20 text-blue-300 border-blue-500/30",
|
||||||
|
Music: "bg-purple-500/20 text-purple-300 border-purple-500/30",
|
||||||
|
Rendering: "bg-indigo-500/20 text-indigo-300 border-indigo-500/30",
|
||||||
|
Validating: "bg-cyan-500/20 text-cyan-300 border-cyan-500/30",
|
||||||
|
Repairing: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
|
||||||
|
Optimisation: "bg-teal-500/20 text-teal-300 border-teal-500/30",
|
||||||
|
Video: "bg-indigo-500/20 text-indigo-300 border-indigo-500/30",
|
||||||
|
Mixing: "bg-purple-500/20 text-purple-300 border-purple-500/30",
|
||||||
|
Final: "bg-teal-500/20 text-teal-300 border-teal-500/30",
|
||||||
|
Uploading: "bg-sky-500/20 text-sky-300 border-sky-500/30",
|
||||||
|
Done: "bg-emerald-500/20 text-emerald-300 border-emerald-500/30",
|
||||||
|
Failed: "bg-red-500/20 text-red-300 border-red-500/30",
|
||||||
|
Cancelled: "bg-gray-500/20 text-gray-500 border-gray-500/20",
|
||||||
|
};
|
||||||
|
|
||||||
|
function relativeTime(iso: string): string {
|
||||||
|
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
||||||
|
if (diff < 60) return `${diff}s ago`;
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
return `${Math.floor(diff / 86400)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RenderQueueTable({ jobs }: { jobs: V2RenderJob[] }) {
|
||||||
|
const t = useTranslations("auto.componentsAdminRenderQueueTable");
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const retryJob = async (jobId: string) => {
|
||||||
|
setLoading((p) => ({ ...p, [jobId]: true }));
|
||||||
|
try {
|
||||||
|
await apiFetch(`/api/admin/renders/${jobId}/retry`, { method: "POST" });
|
||||||
|
router.refresh();
|
||||||
|
} finally {
|
||||||
|
setLoading((p) => ({ ...p, [jobId]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelJob = async (jobId: string) => {
|
||||||
|
setLoading((p) => ({ ...p, [jobId]: true }));
|
||||||
|
try {
|
||||||
|
await apiFetch(`/api/admin/renders/${jobId}/cancel`, { method: "POST" });
|
||||||
|
router.refresh();
|
||||||
|
} finally {
|
||||||
|
setLoading((p) => ({ ...p, [jobId]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (jobs.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-[#1e2235] bg-[#0f1120] px-6 py-16 text-center text-sm text-gray-500">
|
||||||
|
{t("emptyState")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-xl border border-[#1e2235]">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-[#1e2235] bg-[#0f1120] text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
<th className="px-4 py-3">{t("colJobId")}</th>
|
||||||
|
<th className="px-4 py-3">{t("colProject")}</th>
|
||||||
|
<th className="px-4 py-3">{t("colStep")}</th>
|
||||||
|
<th className="px-4 py-3">{t("colProgress")}</th>
|
||||||
|
<th className="px-4 py-3">{t("colQuality")}</th>
|
||||||
|
<th className="px-4 py-3">{t("colNode")}</th>
|
||||||
|
<th className="px-4 py-3">{t("colCreated")}</th>
|
||||||
|
<th className="px-4 py-3">{t("colActions")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-[#1e2235] bg-[#0c0e1a]">
|
||||||
|
{jobs.map((job) => {
|
||||||
|
const stepColor = STEP_COLORS[job.step] ?? STEP_COLORS.Queued;
|
||||||
|
const canRetry = job.step === "Failed" || job.step === "Cancelled";
|
||||||
|
const canCancel = !["Done", "Failed", "Cancelled"].includes(job.step);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={job.id} className="hover:bg-[#0f1120]/60 transition-colors">
|
||||||
|
<td className="px-4 py-3 font-mono text-[11px] text-gray-400">
|
||||||
|
{job.id.slice(0, 12)}…
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-[11px] text-gray-500">
|
||||||
|
{job.saved_project_id.slice(0, 12)}…
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium ${stepColor}`}>
|
||||||
|
{job.step}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-[#1e2235]">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary-600"
|
||||||
|
style={{ width: `${job.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="tabular-nums text-[11px] text-gray-500">
|
||||||
|
{job.progress}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{job.error_message && (
|
||||||
|
<p className="mt-0.5 text-[10px] text-red-400 max-w-[200px] truncate" title={job.error_message}>
|
||||||
|
{job.error_message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-400 text-xs">
|
||||||
|
{job.quality} / {job.resolution}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-[11px] text-gray-500">
|
||||||
|
{job.node_id ? job.node_id.slice(0, 8) + "…" : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500 text-xs">
|
||||||
|
{relativeTime(job.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{canRetry && (
|
||||||
|
<button
|
||||||
|
onClick={() => retryJob(job.id)}
|
||||||
|
disabled={loading[job.id]}
|
||||||
|
className="rounded px-2.5 py-1 text-xs text-emerald-300 border border-emerald-500/30 hover:bg-emerald-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{t("actionRetry")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canCancel && (
|
||||||
|
<button
|
||||||
|
onClick={() => cancelJob(job.id)}
|
||||||
|
disabled={loading[job.id]}
|
||||||
|
className="rounded px-2.5 py-1 text-xs text-red-300 border border-red-500/30 hover:bg-red-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{t("actionCancel")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ResourceConfig } from "@/components/admin/AdminResource";
|
||||||
|
|
||||||
|
const badge = (ok: boolean, yes: string, no: string) =>
|
||||||
|
ok ? (
|
||||||
|
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[11px] text-emerald-300">{yes}</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded bg-gray-500/15 px-1.5 py-0.5 text-[11px] text-gray-400">{no}</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
const banAction = (row: Record<string, unknown>, reload: () => void) => {
|
||||||
|
const banned = !!row.ban_account;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={
|
||||||
|
banned
|
||||||
|
? "rounded-lg border border-emerald-500/30 px-3 py-1.5 text-xs text-emerald-300 hover:bg-emerald-500/10"
|
||||||
|
: "rounded-lg border border-red-500/30 px-3 py-1.5 text-xs text-red-300 hover:bg-red-500/10"
|
||||||
|
}
|
||||||
|
onClick={async () => {
|
||||||
|
const reason = banned ? "" : prompt("Ban reason?") ?? "";
|
||||||
|
if (!banned && !reason) return;
|
||||||
|
const res = await fetch(`/api/admin/resource/users/${row.id}/ban`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ reason: banned ? "unban" : reason, unbanned: banned }),
|
||||||
|
});
|
||||||
|
if (res.ok) reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{banned ? "Unban" : "Ban"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const categoriesConfig: ResourceConfig = {
|
||||||
|
title: "Categories",
|
||||||
|
description: "Taxonomy used across templates and the public site.",
|
||||||
|
basePath: "categories",
|
||||||
|
canCreate: true,
|
||||||
|
canEdit: true,
|
||||||
|
canDelete: true,
|
||||||
|
columns: [
|
||||||
|
{ key: "name", label: "Name" },
|
||||||
|
{ key: "slug", label: "Slug" },
|
||||||
|
{ key: "is_active", label: "Active", render: (r) => badge(!!r.is_active, "active", "hidden") },
|
||||||
|
{ key: "sort", label: "Sort" },
|
||||||
|
],
|
||||||
|
fields: [
|
||||||
|
{ key: "name", label: "Name", required: true },
|
||||||
|
{ key: "slug", label: "Slug", required: true },
|
||||||
|
{ key: "description", label: "Description", type: "textarea" },
|
||||||
|
{ key: "image_url", label: "Image URL" },
|
||||||
|
{ key: "icon", label: "Icon" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const tagsConfig: ResourceConfig = {
|
||||||
|
title: "Tags",
|
||||||
|
description: "Keyword tags for templates and content.",
|
||||||
|
basePath: "tags",
|
||||||
|
listKey: "items",
|
||||||
|
canCreate: true,
|
||||||
|
canEdit: true,
|
||||||
|
canDelete: true,
|
||||||
|
columns: [
|
||||||
|
{ key: "name", label: "Name" },
|
||||||
|
{ key: "slug", label: "Slug" },
|
||||||
|
{ key: "is_active", label: "Active", render: (r) => badge(!!r.is_active, "active", "hidden") },
|
||||||
|
],
|
||||||
|
fields: [
|
||||||
|
{ key: "name", label: "Name", required: true },
|
||||||
|
{ key: "latin_name", label: "Latin name" },
|
||||||
|
{ key: "slug", label: "Slug", required: true },
|
||||||
|
{ key: "is_active", label: "Active", type: "checkbox", defaultValue: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fontsConfig: ResourceConfig = {
|
||||||
|
title: "Fonts",
|
||||||
|
description: "Fonts available in the studio editors.",
|
||||||
|
basePath: "fonts",
|
||||||
|
listKey: "items",
|
||||||
|
canCreate: true,
|
||||||
|
canEdit: true,
|
||||||
|
canDelete: true,
|
||||||
|
columns: [
|
||||||
|
{ key: "name", label: "Name" },
|
||||||
|
{ key: "family", label: "Family" },
|
||||||
|
{ key: "weight", label: "Weight" },
|
||||||
|
{ key: "style", label: "Style" },
|
||||||
|
],
|
||||||
|
fields: [
|
||||||
|
{ key: "name", label: "Name", required: true },
|
||||||
|
{ key: "original_name", label: "Original name" },
|
||||||
|
{ key: "system_name", label: "System name" },
|
||||||
|
{ key: "family", label: "Family" },
|
||||||
|
{ key: "weight", label: "Weight", type: "number" },
|
||||||
|
{ key: "style", label: "Style" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const blogsConfig: ResourceConfig = {
|
||||||
|
title: "Blog Posts",
|
||||||
|
description: "CMS articles (also created by the AI SEO generator).",
|
||||||
|
basePath: "blogs",
|
||||||
|
listKey: "items",
|
||||||
|
canCreate: true,
|
||||||
|
canEdit: true,
|
||||||
|
canDelete: true,
|
||||||
|
columns: [
|
||||||
|
{ key: "title", label: "Title" },
|
||||||
|
{ key: "slug", label: "Slug" },
|
||||||
|
{ key: "is_published", label: "Published", render: (r) => badge(!!r.is_published, "live", "draft") },
|
||||||
|
{ key: "view_count", label: "Views" },
|
||||||
|
],
|
||||||
|
fields: [
|
||||||
|
{ key: "title", label: "Title", required: true },
|
||||||
|
{ key: "slug", label: "Slug", required: true },
|
||||||
|
{ key: "short_description", label: "Short description", type: "textarea" },
|
||||||
|
{ key: "content", label: "Content (HTML)", type: "textarea", required: true },
|
||||||
|
{ key: "meta_title", label: "Meta title" },
|
||||||
|
{ key: "meta_description", label: "Meta description", type: "textarea" },
|
||||||
|
{ key: "meta_keywords", label: "Meta keywords" },
|
||||||
|
{ key: "is_published", label: "Published", type: "checkbox" },
|
||||||
|
{ key: "include_in_site_map", label: "Include in sitemap", type: "checkbox", defaultValue: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const slidesConfig: ResourceConfig = {
|
||||||
|
title: "Home Slides",
|
||||||
|
description: "Hero/promo slides on the homepage.",
|
||||||
|
basePath: "slides",
|
||||||
|
canDelete: true,
|
||||||
|
columns: [
|
||||||
|
{ key: "title", label: "Title" },
|
||||||
|
{ key: "slide_type", label: "Type" },
|
||||||
|
{ key: "is_active", label: "Active", render: (r) => badge(!!r.is_active, "active", "hidden") },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usersConfig: ResourceConfig = {
|
||||||
|
title: "Users",
|
||||||
|
description: "Accounts in this tenant. Ban or unban below.",
|
||||||
|
basePath: "users",
|
||||||
|
listKey: "data",
|
||||||
|
columns: [
|
||||||
|
{ key: "email", label: "Email" },
|
||||||
|
{ key: "full_name", label: "Name" },
|
||||||
|
{ key: "is_admin", label: "Admin", render: (r) => badge(!!r.is_admin, "admin", "—") },
|
||||||
|
{ key: "register_mode", label: "Source" },
|
||||||
|
{ key: "ban_account", label: "Status", render: (r) => badge(!r.ban_account, "active", "banned") },
|
||||||
|
],
|
||||||
|
rowActions: banAction,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const plansConfig: ResourceConfig = {
|
||||||
|
title: "Plans",
|
||||||
|
description: "Subscription plans (read-only view).",
|
||||||
|
basePath: "plans",
|
||||||
|
listKey: "data",
|
||||||
|
columns: [
|
||||||
|
{ key: "code", label: "Code" },
|
||||||
|
{ key: "name", label: "Name" },
|
||||||
|
{ key: "price_minor", label: "Price (minor)" },
|
||||||
|
{ key: "billing_period", label: "Period" },
|
||||||
|
{ key: "is_active", label: "Active", render: (r) => badge(!!r.is_active, "active", "off") },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from "next/navigation";
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { AuthLoadingSpinner } from "@/components/auth/AuthLoadingSpinner";
|
import { AuthLoadingSpinner } from "@/components/auth/AuthLoadingSpinner";
|
||||||
import { authFormSchema, type AuthFormValues } from "@/components/auth/auth-schemas";
|
import { authFormSchema, type AuthFormValues } from "@/components/auth/auth-schemas";
|
||||||
@@ -13,6 +14,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type AuthTab = "sign-in" | "sign-up";
|
type AuthTab = "sign-in" | "sign-up";
|
||||||
|
type AuthView = "main" | "forgot-password" | "reset-confirm";
|
||||||
|
|
||||||
/** Only allow same-origin relative redirects to avoid open-redirect issues. */
|
/** Only allow same-origin relative redirects to avoid open-redirect issues. */
|
||||||
function safeNext(next: string | null): string {
|
function safeNext(next: string | null): string {
|
||||||
@@ -21,6 +23,7 @@ function safeNext(next: string | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AuthPageContent() {
|
export function AuthPageContent() {
|
||||||
|
const t = useTranslations("auto.componentsAuthAuthPageContent");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const nextPath = safeNext(searchParams.get("next"));
|
const nextPath = safeNext(searchParams.get("next"));
|
||||||
@@ -29,11 +32,17 @@ export function AuthPageContent() {
|
|||||||
searchParams.get("tab") === "sign-up" ? "sign-up" : "sign-in";
|
searchParams.get("tab") === "sign-up" ? "sign-up" : "sign-in";
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<AuthTab>(initialTab);
|
const [activeTab, setActiveTab] = useState<AuthTab>(initialTab);
|
||||||
|
const [view, setView] = useState<AuthView>("main");
|
||||||
const [authLoading, setAuthLoading] = useState(true);
|
const [authLoading, setAuthLoading] = useState(true);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
const [formMessage, setFormMessage] = useState<string | null>(null);
|
const [formMessage, setFormMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Forgot-password state
|
||||||
|
const [resetEmail, setResetEmail] = useState("");
|
||||||
|
const [resetOtp, setResetOtp] = useState("");
|
||||||
|
const [resetNewPassword, setResetNewPassword] = useState("");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@@ -64,9 +73,7 @@ export function AuthPageContent() {
|
|||||||
checkSession().then((redirected) => {
|
checkSession().then((redirected) => {
|
||||||
if (mounted && !redirected) setAuthLoading(false);
|
if (mounted && !redirected) setAuthLoading(false);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => { mounted = false; };
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, [checkSession]);
|
}, [checkSession]);
|
||||||
|
|
||||||
const handleTabChange = (tab: AuthTab) => {
|
const handleTabChange = (tab: AuthTab) => {
|
||||||
@@ -76,13 +83,22 @@ export function AuthPageContent() {
|
|||||||
reset();
|
reset();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
setView("main");
|
||||||
|
setFormError(null);
|
||||||
|
setFormMessage(null);
|
||||||
|
setResetEmail("");
|
||||||
|
setResetOtp("");
|
||||||
|
setResetNewPassword("");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Main sign-in / sign-up submit ──────────────────────────────────────────
|
||||||
const onSubmit = async (values: AuthFormValues) => {
|
const onSubmit = async (values: AuthFormValues) => {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
setFormMessage(null);
|
setFormMessage(null);
|
||||||
|
|
||||||
const endpoint =
|
const endpoint = activeTab === "sign-in" ? "/api/auth/login" : "/api/auth/register";
|
||||||
activeTab === "sign-in" ? "/api/auth/login" : "/api/auth/register";
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(endpoint, {
|
const res = await fetch(endpoint, {
|
||||||
@@ -93,17 +109,16 @@ export function AuthPageContent() {
|
|||||||
const data = await res.json().catch(() => null);
|
const data = await res.json().catch(() => null);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setFormError(data?.error ?? "Something went wrong. Please try again.");
|
setFormError(data?.error ?? t("genericError"));
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Registered but not auto-logged-in (verification gate) — prompt sign-in.
|
|
||||||
if (data?.registered && !data?.user) {
|
if (data?.registered && !data?.user) {
|
||||||
setFormMessage(
|
setFormMessage(
|
||||||
data.verificationRequired
|
data.verificationRequired
|
||||||
? "Account created. Check your email to verify, then sign in."
|
? t("accountCreatedVerify")
|
||||||
: "Account created. Please sign in."
|
: t("accountCreatedSignIn")
|
||||||
);
|
);
|
||||||
setActiveTab("sign-in");
|
setActiveTab("sign-in");
|
||||||
reset();
|
reset();
|
||||||
@@ -111,11 +126,58 @@ export function AuthPageContent() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logged in — cookies are set; refresh server components and go.
|
|
||||||
router.replace(nextPath);
|
router.replace(nextPath);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch {
|
} catch {
|
||||||
setFormError("Network error. Please try again.");
|
setFormError(t("networkError"));
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Forgot password — step 1: request OTP ─────────────────────────────────
|
||||||
|
const handleForgotRequest = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!resetEmail) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setFormError(null);
|
||||||
|
try {
|
||||||
|
await fetch("/api/auth/password-reset", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email: resetEmail }),
|
||||||
|
});
|
||||||
|
// Always succeed (anti-enumeration)
|
||||||
|
setView("reset-confirm");
|
||||||
|
setFormMessage(t("resetCodeSent"));
|
||||||
|
} catch {
|
||||||
|
setFormError(t("networkError"));
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Forgot password — step 2: confirm OTP + new password ──────────────────
|
||||||
|
const handleResetConfirm = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!resetOtp || !resetNewPassword) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setFormError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/password-reset-confirm", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email: resetEmail, otp: resetOtp, new_password: resetNewPassword }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => null) as { error?: string } | null;
|
||||||
|
if (!res.ok) {
|
||||||
|
setFormError(data?.error ?? t("invalidCode"));
|
||||||
|
} else {
|
||||||
|
setFormMessage(t("passwordUpdated"));
|
||||||
|
goBack();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setFormError(t("networkError"));
|
||||||
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -123,21 +185,111 @@ export function AuthPageContent() {
|
|||||||
if (authLoading) {
|
if (authLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center py-20">
|
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center py-20">
|
||||||
<AuthLoadingSpinner label="Checking authentication..." />
|
<AuthLoadingSpinner label={t("checkingAuth")} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Forgot password views ──────────────────────────────────────────────────
|
||||||
|
if (view === "forgot-password" || view === "reset-confirm") {
|
||||||
|
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-2xl font-bold text-neutral-900">
|
||||||
|
{view === "forgot-password" ? t("resetTitle") : t("enterCodeTitle")}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-neutral-600">
|
||||||
|
{view === "forgot-password"
|
||||||
|
? t("resetSubtitle")
|
||||||
|
: t("enterCodeSubtitle", { email: resetEmail })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 rounded-xl border border-gray-100 bg-white p-6 shadow-sm">
|
||||||
|
{view === "forgot-password" ? (
|
||||||
|
<form onSubmit={handleForgotRequest} className="space-y-4" noValidate>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="reset-email" className="block text-sm font-medium text-neutral-700">
|
||||||
|
{t("emailAddressLabel")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="reset-email"
|
||||||
|
type="email"
|
||||||
|
value={resetEmail}
|
||||||
|
onChange={(e) => setResetEmail(e.target.value)}
|
||||||
|
disabled={submitting}
|
||||||
|
required
|
||||||
|
className="mt-1.5 w-full rounded-lg border border-gray-100 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 disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{formError && <p className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{formError}</p>}
|
||||||
|
<Button type="submit" className="w-full" disabled={submitting || !resetEmail}>
|
||||||
|
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{t("sendResetCode")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleResetConfirm} className="space-y-4" noValidate>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="reset-otp" className="block text-sm font-medium text-neutral-700">
|
||||||
|
{t("resetCodeLabel")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="reset-otp"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={resetOtp}
|
||||||
|
onChange={(e) => setResetOtp(e.target.value)}
|
||||||
|
disabled={submitting}
|
||||||
|
required
|
||||||
|
placeholder={t("resetCodePlaceholder")}
|
||||||
|
className="mt-1.5 w-full rounded-lg border border-gray-100 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 disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="new-password" className="block text-sm font-medium text-neutral-700">
|
||||||
|
{t("newPasswordLabel")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="new-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={resetNewPassword}
|
||||||
|
onChange={(e) => setResetNewPassword(e.target.value)}
|
||||||
|
disabled={submitting}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="mt-1.5 w-full rounded-lg border border-gray-100 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 disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
</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={submitting || !resetOtp || !resetNewPassword}>
|
||||||
|
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{t("setNewPassword")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" onClick={goBack} className="mt-4 block w-full text-center text-sm text-neutral-500 hover:text-neutral-700 transition-colors">
|
||||||
|
← {t("backToSignIn")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main sign-in / sign-up view ────────────────────────────────────────────
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-md px-4 py-12 sm:py-16">
|
<div className="mx-auto w-full max-w-md px-4 py-12 sm:py-16">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="font-heading text-3xl font-bold text-neutral-900">
|
<h1 className="font-heading text-3xl font-bold text-neutral-900">
|
||||||
Welcome to FlatRender
|
{t("welcomeTitle")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-neutral-600">
|
<p className="mt-2 text-sm text-neutral-600">
|
||||||
{activeTab === "sign-in"
|
{activeTab === "sign-in"
|
||||||
? "Sign in to continue to your dashboard"
|
? t("signInSubtitle")
|
||||||
: "Create a free account to get started"}
|
: t("signUpSubtitle")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -154,7 +306,7 @@ export function AuthPageContent() {
|
|||||||
: "text-neutral-600 hover:text-neutral-900"
|
: "text-neutral-600 hover:text-neutral-900"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{tab === "sign-in" ? "Sign In" : "Sign Up"}
|
{tab === "sign-in" ? t("signInTab") : t("signUpTab")}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -162,11 +314,8 @@ export function AuthPageContent() {
|
|||||||
<div className="mt-6 rounded-xl border border-gray-100 bg-white p-6 shadow-sm">
|
<div className="mt-6 rounded-xl border border-gray-100 bg-white p-6 shadow-sm">
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label htmlFor="email" className="block text-sm font-medium text-neutral-700">
|
||||||
htmlFor="email"
|
{t("emailLabel")}
|
||||||
className="block text-sm font-medium text-neutral-700"
|
|
||||||
>
|
|
||||||
Email
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
@@ -185,18 +334,24 @@ export function AuthPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<div className="flex items-center justify-between">
|
||||||
htmlFor="password"
|
<label htmlFor="password" className="block text-sm font-medium text-neutral-700">
|
||||||
className="block text-sm font-medium text-neutral-700"
|
{t("passwordLabel")}
|
||||||
>
|
|
||||||
Password
|
|
||||||
</label>
|
</label>
|
||||||
|
{activeTab === "sign-in" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setView("forgot-password"); setFormError(null); setFormMessage(null); }}
|
||||||
|
className="text-xs text-primary-600 hover:underline focus-visible:outline-none"
|
||||||
|
>
|
||||||
|
{t("forgotPassword")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete={
|
autoComplete={activeTab === "sign-in" ? "current-password" : "new-password"}
|
||||||
activeTab === "sign-in" ? "current-password" : "new-password"
|
|
||||||
}
|
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className={cn(
|
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",
|
"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",
|
||||||
@@ -205,43 +360,37 @@ export function AuthPageContent() {
|
|||||||
{...register("password")}
|
{...register("password")}
|
||||||
/>
|
/>
|
||||||
{errors.password && (
|
{errors.password && (
|
||||||
<p className="mt-1.5 text-xs text-red-600">
|
<p className="mt-1.5 text-xs text-red-600">{errors.password.message}</p>
|
||||||
{errors.password.message}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{formError && (
|
{formError && (
|
||||||
<p className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
|
<p className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{formError}</p>
|
||||||
{formError}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{formMessage && (
|
{formMessage && (
|
||||||
<p className="rounded-lg bg-primary-50 px-3 py-2 text-sm text-primary-700">
|
<p className="rounded-lg bg-primary-50 px-3 py-2 text-sm text-primary-700">{formMessage}</p>
|
||||||
{formMessage}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={submitting}>
|
<Button type="submit" className="w-full" disabled={submitting}>
|
||||||
{submitting ? (
|
{submitting ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : null}
|
||||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
{activeTab === "sign-in" ? t("signInTab") : t("createAccount")}
|
||||||
) : null}
|
|
||||||
{activeTab === "sign-in" ? "Sign In" : "Create Account"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-6 text-center text-xs text-neutral-500">
|
<p className="mt-6 text-center text-xs text-neutral-500">
|
||||||
By continuing, you agree to our{" "}
|
{t.rich("legalNotice", {
|
||||||
|
terms: (chunks) => (
|
||||||
<Link href="/terms" className="text-primary-600 hover:underline">
|
<Link href="/terms" className="text-primary-600 hover:underline">
|
||||||
Terms
|
{chunks}
|
||||||
</Link>{" "}
|
|
||||||
and{" "}
|
|
||||||
<Link href="/privacy" className="text-primary-600 hover:underline">
|
|
||||||
Privacy Policy
|
|
||||||
</Link>
|
</Link>
|
||||||
.
|
),
|
||||||
|
privacy: (chunks) => (
|
||||||
|
<Link href="/privacy" className="text-primary-600 hover:underline">
|
||||||
|
{chunks}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ interface SupabaseSetupNoticeProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SupabaseSetupNotice({ nextPath }: SupabaseSetupNoticeProps) {
|
export function SupabaseSetupNotice({ nextPath }: SupabaseSetupNoticeProps) {
|
||||||
|
const t = useTranslations("auto.componentsAuthSupabaseSetupNotice");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isDev = process.env.NODE_ENV === "development";
|
const isDev = process.env.NODE_ENV === "development";
|
||||||
const continueHref = nextPath?.startsWith("/") ? nextPath : "/dashboard";
|
const continueHref = nextPath?.startsWith("/") ? nextPath : "/dashboard";
|
||||||
@@ -18,15 +20,23 @@ export function SupabaseSetupNotice({ nextPath }: SupabaseSetupNoticeProps) {
|
|||||||
<div className="mx-auto w-full max-w-md px-4 py-12 sm:py-16">
|
<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">
|
<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">
|
<h1 className="font-heading text-xl font-bold text-neutral-900">
|
||||||
Supabase not configured
|
{t("title")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-3 text-sm text-neutral-600">
|
<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>{" "}
|
{t.rich("instructions", {
|
||||||
to <code className="rounded bg-white px-1.5 py-0.5 text-xs">.env.local</code> and set{" "}
|
envExample: () => (
|
||||||
<code className="rounded bg-white px-1.5 py-0.5 text-xs">NEXT_PUBLIC_SUPABASE_URL</code>{" "}
|
<code className="rounded bg-white px-1.5 py-0.5 text-xs">.env.example</code>
|
||||||
and{" "}
|
),
|
||||||
|
envLocal: () => (
|
||||||
|
<code className="rounded bg-white px-1.5 py-0.5 text-xs">.env.local</code>
|
||||||
|
),
|
||||||
|
supabaseUrl: () => (
|
||||||
|
<code className="rounded bg-white px-1.5 py-0.5 text-xs">NEXT_PUBLIC_SUPABASE_URL</code>
|
||||||
|
),
|
||||||
|
supabaseAnonKey: () => (
|
||||||
<code className="rounded bg-white px-1.5 py-0.5 text-xs">NEXT_PUBLIC_SUPABASE_ANON_KEY</code>
|
<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>
|
</p>
|
||||||
{isDev ? (
|
{isDev ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -34,11 +44,11 @@ export function SupabaseSetupNotice({ nextPath }: SupabaseSetupNoticeProps) {
|
|||||||
className="mt-6 w-full"
|
className="mt-6 w-full"
|
||||||
onClick={() => router.push(continueHref)}
|
onClick={() => router.push(continueHref)}
|
||||||
>
|
>
|
||||||
Continue without signing in (dev only)
|
{t("continueDev")}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button type="button" className="mt-6 w-full" asChild>
|
<Button type="button" className="mt-6 w-full" asChild>
|
||||||
<Link href="/">Back to home</Link>
|
<Link href="/">{t("backToHome")}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,24 +1,25 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FolderOpen } from "lucide-react";
|
import { FolderOpen } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { NewProjectMenu } from "@/components/dashboard/NewProjectMenu";
|
import { NewProjectMenu } from "@/components/dashboard/NewProjectMenu";
|
||||||
|
|
||||||
export function DashboardEmptyState() {
|
export function DashboardEmptyState() {
|
||||||
|
const t = useTranslations("auto.componentsDashboardDashboardEmptyState");
|
||||||
return (
|
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 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">
|
<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 />
|
<FolderOpen className="h-10 w-10" aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="mt-6 font-heading text-xl font-semibold text-neutral-900">
|
<h3 className="mt-6 font-heading text-xl font-semibold text-neutral-900">
|
||||||
No projects yet
|
{t("title")}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-2 max-w-sm text-sm text-neutral-600">
|
<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
|
{t("description")}
|
||||||
save appears in this workspace.
|
|
||||||
</p>
|
</p>
|
||||||
<NewProjectMenu
|
<NewProjectMenu
|
||||||
triggerLabel="Create your first project"
|
triggerLabel={t("createFirstProject")}
|
||||||
triggerClassName="mt-8 gap-2"
|
triggerClassName="mt-8 gap-2"
|
||||||
align="center"
|
align="center"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { getUserProfile } from "@/lib/profiles";
|
import { getUserProfile } from "@/lib/profiles";
|
||||||
@@ -16,6 +17,7 @@ interface DashboardPlanBadgeProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function DashboardPlanBadge({ userId }: DashboardPlanBadgeProps) {
|
export async function DashboardPlanBadge({ userId }: DashboardPlanBadgeProps) {
|
||||||
|
const t = await getTranslations("auto.componentsDashboardDashboardPlanBadge");
|
||||||
const profile = await getUserProfile(userId);
|
const profile = await getUserProfile(userId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -30,7 +32,7 @@ export async function DashboardPlanBadge({ userId }: DashboardPlanBadgeProps) {
|
|||||||
</p>
|
</p>
|
||||||
{profile.plan !== "business" ? (
|
{profile.plan !== "business" ? (
|
||||||
<Button size="sm" className="mt-3 w-full" asChild>
|
<Button size="sm" className="mt-3 w-full" asChild>
|
||||||
<Link href="/#pricing">Upgrade plan</Link>
|
<Link href="/#pricing">{t("upgradePlan")}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { DashboardEmptyState } from "@/components/dashboard/DashboardEmptyState";
|
import { DashboardEmptyState } from "@/components/dashboard/DashboardEmptyState";
|
||||||
import { DashboardTopBar } from "@/components/dashboard/DashboardTopBar";
|
import { DashboardTopBar } from "@/components/dashboard/DashboardTopBar";
|
||||||
@@ -19,6 +20,7 @@ export function DashboardProjectsSection({
|
|||||||
projects = [],
|
projects = [],
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
}: DashboardProjectsSectionProps) {
|
}: DashboardProjectsSectionProps) {
|
||||||
|
const t = useTranslations("auto.componentsDashboardDashboardProjectsSection");
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
const filteredProjects = useMemo(() => {
|
const filteredProjects = useMemo(() => {
|
||||||
@@ -42,7 +44,7 @@ export function DashboardProjectsSection({
|
|||||||
|
|
||||||
<div className="flex-1 overflow-auto p-6">
|
<div className="flex-1 overflow-auto p-6">
|
||||||
<h2 className="font-heading text-xl font-bold text-neutral-900">
|
<h2 className="font-heading text-xl font-bold text-neutral-900">
|
||||||
Recent Projects
|
{t("recentProjects")}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{showEmpty && (
|
{showEmpty && (
|
||||||
@@ -62,10 +64,10 @@ export function DashboardProjectsSection({
|
|||||||
{showNoResults && (
|
{showNoResults && (
|
||||||
<div className="mt-8 rounded-xl border border-dashed border-gray-200 bg-neutral-50 px-6 py-12 text-center">
|
<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">
|
<p className="font-heading text-lg font-semibold text-neutral-900">
|
||||||
No projects match your search
|
{t("noResultsTitle")}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-sm text-neutral-600">
|
<p className="mt-2 text-sm text-neutral-600">
|
||||||
Try a different keyword or clear the search bar.
|
{t("noResultsDescription")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
import { LogoMark } from "@/components/ui/LogoMark";
|
import { LogoMark } from "@/components/ui/LogoMark";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -26,11 +27,12 @@ function getInitials(email: string, name?: string | null): string {
|
|||||||
return email.slice(0, 2).toUpperCase();
|
return email.slice(0, 2).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardSidebar({
|
export async function DashboardSidebar({
|
||||||
userEmail,
|
userEmail,
|
||||||
userName,
|
userName,
|
||||||
userId,
|
userId,
|
||||||
}: DashboardSidebarProps) {
|
}: DashboardSidebarProps) {
|
||||||
|
const t = await getTranslations("auto.componentsDashboardDashboardSidebar");
|
||||||
const initials = getInitials(userEmail, userName);
|
const initials = getInitials(userEmail, userName);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -52,7 +54,7 @@ export function DashboardSidebar({
|
|||||||
<div className="border-t border-gray-100 p-4">
|
<div className="border-t border-gray-100 p-4">
|
||||||
<div className="mb-3 rounded-lg border border-gray-100 bg-neutral-50 p-3">
|
<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">
|
<p className="text-xs font-medium uppercase tracking-wide text-neutral-500">
|
||||||
Current plan
|
{t("currentPlan")}
|
||||||
</p>
|
</p>
|
||||||
<Suspense fallback={<DashboardPlanBadgeSkeleton />}>
|
<Suspense fallback={<DashboardPlanBadgeSkeleton />}>
|
||||||
<DashboardPlanBadge userId={userId} />
|
<DashboardPlanBadge userId={userId} />
|
||||||
@@ -78,7 +80,7 @@ export function DashboardSidebar({
|
|||||||
type="submit"
|
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"
|
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
|
{t("signOut")}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import {
|
import {
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
LayoutTemplate,
|
LayoutTemplate,
|
||||||
@@ -12,17 +13,18 @@ import {
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ label: "My Projects", href: "/dashboard", icon: FolderOpen },
|
{ labelKey: "myProjects", href: "/dashboard", icon: FolderOpen },
|
||||||
{ label: "Templates", href: "/templates", icon: LayoutTemplate },
|
{ labelKey: "templates", href: "/templates", icon: LayoutTemplate },
|
||||||
{ label: "Upgrade", href: "/#pricing", icon: Zap },
|
{ labelKey: "upgrade", href: "/#pricing", icon: Zap },
|
||||||
{ label: "Settings", href: "/dashboard/settings", icon: Settings },
|
{ labelKey: "settings", href: "/dashboard/settings", icon: Settings },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function DashboardSidebarNav() {
|
export function DashboardSidebarNav() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const t = useTranslations("auto.componentsDashboardDashboardSidebarNav");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="flex-1 space-y-1 px-3 py-4" aria-label="Dashboard">
|
<nav className="flex-1 space-y-1 px-3 py-4" aria-label={t("navLabel")}>
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const isActive =
|
const isActive =
|
||||||
@@ -32,7 +34,7 @@ export function DashboardSidebarNav() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.label}
|
key={item.labelKey}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={cn(
|
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",
|
"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",
|
||||||
@@ -42,7 +44,7 @@ export function DashboardSidebarNav() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4 shrink-0" aria-hidden />
|
<Icon className="h-4 w-4 shrink-0" aria-hidden />
|
||||||
{item.label}
|
{t(item.labelKey)}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Search } from "lucide-react";
|
import { Search } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { NewProjectMenu } from "@/components/dashboard/NewProjectMenu";
|
import { NewProjectMenu } from "@/components/dashboard/NewProjectMenu";
|
||||||
|
|
||||||
@@ -13,6 +14,8 @@ export function DashboardTopBar({
|
|||||||
searchQuery,
|
searchQuery,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
}: DashboardTopBarProps) {
|
}: DashboardTopBarProps) {
|
||||||
|
const t = useTranslations("auto.componentsDashboardDashboardTopBar");
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
<label className="relative max-w-md flex-1">
|
||||||
@@ -24,7 +27,7 @@ export function DashboardTopBar({
|
|||||||
type="search"
|
type="search"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(event) => onSearchChange(event.target.value)}
|
onChange={(event) => onSearchChange(event.target.value)}
|
||||||
placeholder="Search projects..."
|
placeholder={t("searchPlaceholder")}
|
||||||
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"
|
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>
|
</label>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ChevronDown, Clapperboard, ImageIcon, Plus, Scissors } from "lucide-react";
|
import { ChevronDown, Clapperboard, ImageIcon, Plus, Scissors } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -20,12 +21,14 @@ interface NewProjectMenuProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function NewProjectMenu({
|
export function NewProjectMenu({
|
||||||
triggerLabel = "New Project",
|
triggerLabel,
|
||||||
triggerClassName,
|
triggerClassName,
|
||||||
align = "end",
|
align = "end",
|
||||||
}: NewProjectMenuProps) {
|
}: NewProjectMenuProps) {
|
||||||
|
const t = useTranslations("auto.componentsDashboardNewProjectMenu");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const label = triggerLabel ?? t("newProject");
|
||||||
|
|
||||||
const createProject = async (type: ProjectType) => {
|
const createProject = async (type: ProjectType) => {
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
@@ -64,7 +67,7 @@ export function NewProjectMenu({
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button className={triggerClassName} disabled={isCreating}>
|
<Button className={triggerClassName} disabled={isCreating}>
|
||||||
<Plus className="h-4 w-4" aria-hidden />
|
<Plus className="h-4 w-4" aria-hidden />
|
||||||
{isCreating ? "Creating…" : triggerLabel}
|
{isCreating ? t("creating") : label}
|
||||||
<ChevronDown className="h-4 w-4 opacity-80" aria-hidden />
|
<ChevronDown className="h-4 w-4 opacity-80" aria-hidden />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -74,21 +77,21 @@ export function NewProjectMenu({
|
|||||||
onClick={() => router.push("/studio/video/new")}
|
onClick={() => router.push("/studio/video/new")}
|
||||||
>
|
>
|
||||||
<Clapperboard className="h-4 w-4 text-primary-600" />
|
<Clapperboard className="h-4 w-4 text-primary-600" />
|
||||||
Video Project
|
{t("videoProject")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="cursor-pointer gap-2"
|
className="cursor-pointer gap-2"
|
||||||
onClick={() => createProject("image")}
|
onClick={() => createProject("image")}
|
||||||
>
|
>
|
||||||
<ImageIcon className="h-4 w-4 text-violet-600" />
|
<ImageIcon className="h-4 w-4 text-violet-600" />
|
||||||
Image Project
|
{t("imageProject")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="cursor-pointer gap-2"
|
className="cursor-pointer gap-2"
|
||||||
onClick={() => createProject("trimmer")}
|
onClick={() => createProject("trimmer")}
|
||||||
>
|
>
|
||||||
<Scissors className="h-4 w-4 text-amber-600" />
|
<Scissors className="h-4 w-4 text-amber-600" />
|
||||||
Trim/Crop Video
|
{t("trimCropVideo")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { Copy, Download, ExternalLink, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
import { Copy, Download, ExternalLink, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
@@ -39,14 +40,14 @@ function statusBadgeClass(status: DashboardProject["status"]): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusLabel(status: DashboardProject["status"]): string {
|
function statusLabelKey(status: DashboardProject["status"]): "statusRendering" | "statusReady" | "statusDraft" {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "rendering":
|
case "rendering":
|
||||||
return "Rendering";
|
return "statusRendering";
|
||||||
case "ready":
|
case "ready":
|
||||||
return "Ready";
|
return "statusReady";
|
||||||
default:
|
default:
|
||||||
return "Draft";
|
return "statusDraft";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +67,7 @@ function typeBadgeClass(type: DashboardProject["type"]): string {
|
|||||||
const fadeTransition = { duration: 0.25, ease: "easeOut" as const };
|
const fadeTransition = { duration: 0.25, ease: "easeOut" as const };
|
||||||
|
|
||||||
export function ProjectCard({ project }: ProjectCardProps) {
|
export function ProjectCard({ project }: ProjectCardProps) {
|
||||||
|
const t = useTranslations("auto.componentsDashboardProjectCard");
|
||||||
const studioPath = getProjectStudioPath(project);
|
const studioPath = getProjectStudioPath(project);
|
||||||
const showRenderStatus = project.type === "video";
|
const showRenderStatus = project.type === "video";
|
||||||
|
|
||||||
@@ -133,7 +135,7 @@ export function ProjectCard({ project }: ProjectCardProps) {
|
|||||||
>
|
>
|
||||||
<Link href={studioPath}>
|
<Link href={studioPath}>
|
||||||
<ExternalLink className="h-3.5 w-3.5" />
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
Open in Studio
|
{t("openInStudio")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
{project.status === "ready" && project.renderUrl ? (
|
{project.status === "ready" && project.renderUrl ? (
|
||||||
@@ -145,7 +147,7 @@ export function ProjectCard({ project }: ProjectCardProps) {
|
|||||||
>
|
>
|
||||||
<a href={project.renderUrl} download>
|
<a href={project.renderUrl} download>
|
||||||
<Download className="h-3.5 w-3.5" />
|
<Download className="h-3.5 w-3.5" />
|
||||||
Download
|
{t("download")}
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -173,7 +175,7 @@ export function ProjectCard({ project }: ProjectCardProps) {
|
|||||||
statusBadgeClass(project.status)
|
statusBadgeClass(project.status)
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{statusLabel(project.status)}
|
{t(statusLabelKey(project.status))}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="text-xs text-neutral-500">
|
<span className="text-xs text-neutral-500">
|
||||||
@@ -185,7 +187,7 @@ export function ProjectCard({ project }: ProjectCardProps) {
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<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"
|
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}`}
|
aria-label={t("actionsFor", { name: project.name })}
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -193,30 +195,30 @@ export function ProjectCard({ project }: ProjectCardProps) {
|
|||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href={studioPath} className="gap-2">
|
<Link href={studioPath} className="gap-2">
|
||||||
<ExternalLink className="h-4 w-4" />
|
<ExternalLink className="h-4 w-4" />
|
||||||
Open in Studio
|
{t("openInStudio")}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{project.renderUrl ? (
|
{project.renderUrl ? (
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<a href={project.renderUrl} download className="gap-2">
|
<a href={project.renderUrl} download className="gap-2">
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
Download
|
{t("download")}
|
||||||
</a>
|
</a>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem className="gap-2">
|
<DropdownMenuItem className="gap-2">
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
Rename
|
{t("rename")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem className="gap-2">
|
<DropdownMenuItem className="gap-2">
|
||||||
<Copy className="h-4 w-4" />
|
<Copy className="h-4 w-4" />
|
||||||
Duplicate
|
{t("duplicate")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem className="gap-2 text-red-600 focus:text-red-600">
|
<DropdownMenuItem className="gap-2 text-red-600 focus:text-red-600">
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
Delete
|
{t("delete")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import { CreditCard, ExternalLink, Zap } from "lucide-react";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { CreditCard, Loader2, Zap } from "lucide-react";
|
||||||
|
|
||||||
|
import { apiFetch } from "@/lib/api/fetch";
|
||||||
import type { PlanId } from "@/lib/plans";
|
import type { PlanId } from "@/lib/plans";
|
||||||
|
|
||||||
interface SettingsBillingProps {
|
interface SettingsBillingProps {
|
||||||
plan: PlanId;
|
plan: PlanId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PLAN_LABELS: Record<PlanId, string> = {
|
const PLAN_LABEL_KEYS: Record<PlanId, string> = {
|
||||||
free: "Free",
|
free: "planFree",
|
||||||
pro: "Pro",
|
pro: "planPro",
|
||||||
business: "Business",
|
business: "planBusiness",
|
||||||
};
|
};
|
||||||
|
|
||||||
const PLAN_COLORS: Record<PlanId, string> = {
|
const PLAN_COLORS: Record<PlanId, string> = {
|
||||||
@@ -18,19 +23,42 @@ const PLAN_COLORS: Record<PlanId, string> = {
|
|||||||
business: "bg-violet-50 text-violet-700",
|
business: "bg-violet-50 text-violet-700",
|
||||||
};
|
};
|
||||||
|
|
||||||
const PLAN_FEATURES: Record<PlanId, string[]> = {
|
const PLAN_FEATURE_KEYS: Record<PlanId, string[]> = {
|
||||||
free: ["5 projects", "720p export", "Community templates"],
|
free: ["featureFree5Projects", "featureFree720pExport", "featureFreeCommunityTemplates"],
|
||||||
pro: ["Unlimited projects", "4K export", "All templates", "Priority render queue", "Custom fonts"],
|
pro: ["featureProUnlimitedProjects", "featurePro4kExport", "featureProAllTemplates", "featureProPriorityRenderQueue", "featureProCustomFonts"],
|
||||||
business: ["Everything in Pro", "Team seats", "White-label export", "API access", "Dedicated support"],
|
business: ["featureBusinessEverythingInPro", "featureBusinessTeamSeats", "featureBusinessWhiteLabelExport", "featureBusinessApiAccess", "featureBusinessDedicatedSupport"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SettingsBilling({ plan }: SettingsBillingProps) {
|
export function SettingsBilling({ plan }: SettingsBillingProps) {
|
||||||
|
const t = useTranslations("auto.componentsDashboardSettingsSettingsBilling");
|
||||||
const isPaid = plan !== "free";
|
const isPaid = plan !== "free";
|
||||||
|
const [cancelling, setCancelling] = useState(false);
|
||||||
|
const [cancelled, setCancelled] = useState(false);
|
||||||
|
const [cancelError, setCancelError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
if (!confirm(t("cancelConfirm"))) return;
|
||||||
|
setCancelling(true);
|
||||||
|
setCancelError(null);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch("/api/billing/cancel", { method: "POST" });
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||||
|
setCancelError(data?.error ?? t("cancelFailed"));
|
||||||
|
} else {
|
||||||
|
setCancelled(true);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setCancelError(t("networkError"));
|
||||||
|
} finally {
|
||||||
|
setCancelling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-gray-100 bg-white p-6">
|
<div className="rounded-xl border border-gray-100 bg-white p-6">
|
||||||
<h2 className="font-heading text-base font-semibold text-neutral-900">Billing & Plan</h2>
|
<h2 className="font-heading text-base font-semibold text-neutral-900">{t("title")}</h2>
|
||||||
<p className="mt-1 text-sm text-neutral-500">Manage your subscription and payment method.</p>
|
<p className="mt-1 text-sm text-neutral-500">{t("subtitle")}</p>
|
||||||
|
|
||||||
{/* Current plan card */}
|
{/* Current plan card */}
|
||||||
<div className="mt-6 rounded-lg border border-gray-100 bg-neutral-50 p-4">
|
<div className="mt-6 rounded-lg border border-gray-100 bg-neutral-50 p-4">
|
||||||
@@ -40,49 +68,74 @@ export function SettingsBilling({ plan }: SettingsBillingProps) {
|
|||||||
<Zap className="h-5 w-5" aria-hidden />
|
<Zap className="h-5 w-5" aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-neutral-500">Current plan</p>
|
<p className="text-xs font-medium uppercase tracking-wide text-neutral-500">{t("currentPlan")}</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<p className="font-heading text-lg font-bold text-neutral-900">{PLAN_LABELS[plan]}</p>
|
<p className="font-heading text-lg font-bold text-neutral-900">{t(PLAN_LABEL_KEYS[plan])}</p>
|
||||||
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${PLAN_COLORS[plan]}`}>
|
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${PLAN_COLORS[plan]}`}>
|
||||||
{isPaid ? "Active" : "Free tier"}
|
{cancelled ? t("statusCancelsAtPeriodEnd") : isPaid ? t("statusActive") : t("statusFreeTier")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isPaid ? (
|
{!isPaid && (
|
||||||
<a
|
|
||||||
href="/api/billing/portal"
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
|
||||||
>
|
|
||||||
<CreditCard className="h-4 w-4" aria-hidden />
|
|
||||||
Manage billing
|
|
||||||
<ExternalLink className="h-3 w-3" aria-hidden />
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<a
|
<a
|
||||||
href="/#pricing"
|
href="/#pricing"
|
||||||
className="inline-flex items-center gap-1.5 rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
className="inline-flex items-center gap-1.5 rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||||
>
|
>
|
||||||
<Zap className="h-4 w-4" aria-hidden />
|
<Zap className="h-4 w-4" aria-hidden />
|
||||||
Upgrade
|
{t("upgrade")}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Features list */}
|
{/* Features list */}
|
||||||
<ul className="mt-4 space-y-1.5">
|
<ul className="mt-4 space-y-1.5">
|
||||||
{PLAN_FEATURES[plan].map((f) => (
|
{PLAN_FEATURE_KEYS[plan].map((key) => (
|
||||||
<li key={f} className="flex items-center gap-2 text-sm text-neutral-600">
|
<li key={key} className="flex items-center gap-2 text-sm text-neutral-600">
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-primary-500" aria-hidden />
|
<span className="h-1.5 w-1.5 rounded-full bg-primary-500" aria-hidden />
|
||||||
{f}
|
{t(key)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Paid plan actions */}
|
||||||
|
{isPaid && !cancelled && (
|
||||||
|
<div className="mt-4 flex items-center gap-3">
|
||||||
|
<a
|
||||||
|
href="/#pricing"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
<CreditCard className="h-4 w-4" aria-hidden />
|
||||||
|
{t("changePlan")}
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={cancelling}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg border border-red-200 px-3 py-1.5 text-sm font-medium text-red-600 transition-colors hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{cancelling ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||||
|
{cancelling ? t("cancelling") : t("cancelPlan")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cancelError && (
|
||||||
|
<p className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
|
{cancelError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cancelled && (
|
||||||
|
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700">
|
||||||
|
{t("cancelledNotice")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isPaid && (
|
{!isPaid && (
|
||||||
<p className="mt-4 text-xs text-neutral-400">
|
<p className="mt-4 text-xs text-neutral-400">
|
||||||
Upgrade to unlock unlimited projects, 4K export, and premium templates.
|
{t("upgradeHint")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,42 +1,44 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
interface Toggle {
|
interface Toggle {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
labelKey: string;
|
||||||
description: string;
|
descriptionKey: string;
|
||||||
defaultOn: boolean;
|
defaultOn: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOGGLES: Toggle[] = [
|
const TOGGLES: Toggle[] = [
|
||||||
{
|
{
|
||||||
id: "render-complete",
|
id: "render-complete",
|
||||||
label: "Render complete",
|
labelKey: "renderCompleteLabel",
|
||||||
description: "Get notified when your video export finishes.",
|
descriptionKey: "renderCompleteDescription",
|
||||||
defaultOn: true,
|
defaultOn: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "project-shared",
|
id: "project-shared",
|
||||||
label: "Project shared with you",
|
labelKey: "projectSharedLabel",
|
||||||
description: "When a team member shares a project.",
|
descriptionKey: "projectSharedDescription",
|
||||||
defaultOn: true,
|
defaultOn: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "weekly-digest",
|
id: "weekly-digest",
|
||||||
label: "Weekly digest",
|
labelKey: "weeklyDigestLabel",
|
||||||
description: "Summary of new templates and platform updates.",
|
descriptionKey: "weeklyDigestDescription",
|
||||||
defaultOn: false,
|
defaultOn: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "product-news",
|
id: "product-news",
|
||||||
label: "Product news",
|
labelKey: "productNewsLabel",
|
||||||
description: "New features, tips, and announcements.",
|
descriptionKey: "productNewsDescription",
|
||||||
defaultOn: false,
|
defaultOn: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function SettingsNotifications() {
|
export function SettingsNotifications() {
|
||||||
|
const t = useTranslations("auto.componentsDashboardSettingsSettingsNotifications");
|
||||||
const [prefs, setPrefs] = useState<Record<string, boolean>>(
|
const [prefs, setPrefs] = useState<Record<string, boolean>>(
|
||||||
Object.fromEntries(TOGGLES.map((t) => [t.id, t.defaultOn]))
|
Object.fromEntries(TOGGLES.map((t) => [t.id, t.defaultOn]))
|
||||||
);
|
);
|
||||||
@@ -55,15 +57,15 @@ export function SettingsNotifications() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-gray-100 bg-white p-6">
|
<div className="rounded-xl border border-gray-100 bg-white p-6">
|
||||||
<h2 className="font-heading text-base font-semibold text-neutral-900">Notifications</h2>
|
<h2 className="font-heading text-base font-semibold text-neutral-900">{t("title")}</h2>
|
||||||
<p className="mt-1 text-sm text-neutral-500">Choose which emails you receive from FlatRender.</p>
|
<p className="mt-1 text-sm text-neutral-500">{t("subtitle")}</p>
|
||||||
|
|
||||||
<div className="mt-6 divide-y divide-gray-100">
|
<div className="mt-6 divide-y divide-gray-100">
|
||||||
{TOGGLES.map((item) => (
|
{TOGGLES.map((item) => (
|
||||||
<div key={item.id} className="flex items-start justify-between gap-4 py-4 first:pt-0 last:pb-0">
|
<div key={item.id} className="flex items-start justify-between gap-4 py-4 first:pt-0 last:pb-0">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-neutral-900">{item.label}</p>
|
<p className="text-sm font-medium text-neutral-900">{t(item.labelKey)}</p>
|
||||||
<p className="mt-0.5 text-xs text-neutral-500">{item.description}</p>
|
<p className="mt-0.5 text-xs text-neutral-500">{t(item.descriptionKey)}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -90,9 +92,9 @@ export function SettingsNotifications() {
|
|||||||
onClick={save}
|
onClick={save}
|
||||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
|
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
Save preferences
|
{t("savePreferences")}
|
||||||
</button>
|
</button>
|
||||||
{saved && <span className="text-sm text-green-600">Saved!</span>}
|
{saved && <span className="text-sm text-green-600">{t("saved")}</span>}
|
||||||
</div>
|
</div>
|
||||||
</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