From 3fc7bf2b97a2731df6b79461f7a9b5483e8eb622 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Tue, 2 Jun 2026 09:35:14 +0330 Subject: [PATCH] feat: AI SEO generator, full admin panel, i18n sweep, new logo + auth/RTL fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI SEO content generator - content-svc: per-tenant OpenAI config (ai_settings) + /v1/ai endpoints (settings GET/PUT, seo-post) with SEO-expert prompt → structured article - admin UI to configure token/base-url/model and generate + save as blog - configurable base URL for restricted networks Full data-driven admin panel - generic /api/admin/resource proxy + reusable AdminResource component - categories/tags/fonts/blogs (CRUD), users (list + ban), plans/slides - AI content section; nav + i18n i18n localization sweep - localized 116 user-facing + studio/editor components to next-intl (fa+en) under the auto.* namespace; merge tooling in scripts/merge-i18n.js Branding + assets - Monoline F logo (LogoMark + favicon) - offline SVG placeholder generator (/api/placeholder), dropped picsum.photos Fixes - JWT issuer mismatch on content/studio (flatrender → flatrender-identity) - missing role claim → [Authorize(Roles="Admin")] now works (RBAC) - Secure cookies broke HTTP sessions → gated behind AUTH_COOKIE_SECURE - Radix RTL via DirectionProvider (right-aligned menus in fa) Co-Authored-By: Claude Opus 4.8 --- .env.example | 3 + .../db/migrations/17_content_ai_settings.sql | 20 + docker-compose.v2.yml | 4 +- messages/en.json | 900 ++++++++++++++++++ messages/fa.json | 900 ++++++++++++++++++ next.config.mjs | 12 +- public/favicon.svg | 12 +- scripts/merge-i18n.js | 47 + .../Application/Services/AiContentService.cs | 177 ++++ .../Controllers/AiController.cs | 51 + .../Domain/Entities/AiSettings.cs | 13 + .../Infrastructure/Data/ContentDbContext.cs | 10 + .../FlatRender.ContentSvc/Models/Ai.cs | 44 + .../content/FlatRender.ContentSvc/Program.cs | 8 +- services/gateway/cmd/server/main.go | 1 + .../Application/Services/TokenService.cs | 4 + .../studio/FlatRender.StudioSvc/Program.cs | 2 +- src/app/[locale]/admin/ai/page.tsx | 7 + src/app/[locale]/admin/blogs/page.tsx | 8 + src/app/[locale]/admin/categories/page.tsx | 8 + src/app/[locale]/admin/fonts/page.tsx | 8 + src/app/[locale]/admin/layout.tsx | 43 +- src/app/[locale]/admin/nodes/page.tsx | 7 +- src/app/[locale]/admin/plans/page.tsx | 8 + src/app/[locale]/admin/renders/page.tsx | 20 +- src/app/[locale]/admin/slides/page.tsx | 8 + src/app/[locale]/admin/tags/page.tsx | 8 + src/app/[locale]/admin/users/page.tsx | 8 + src/app/[locale]/auth/page.tsx | 19 +- src/app/[locale]/dashboard/settings/page.tsx | 12 +- src/app/[locale]/error.tsx | 9 +- src/app/[locale]/image-maker/page.tsx | 15 +- src/app/[locale]/layout.tsx | 6 +- src/app/[locale]/not-found.tsx | 11 +- src/app/[locale]/page.tsx | 15 +- .../studio/image/[projectId]/page.tsx | 16 +- src/app/[locale]/studio/trimmer/page.tsx | 15 +- .../studio/video/[projectId]/page.tsx | 16 +- src/app/[locale]/video-maker/page.tsx | 15 +- src/app/api/admin/ai/_aiProxy.ts | 50 + src/app/api/admin/ai/generate/route.ts | 9 + src/app/api/admin/ai/save/route.ts | 10 + src/app/api/admin/ai/settings/route.ts | 13 + src/app/api/admin/resource/[...path]/route.ts | 96 ++ .../api/placeholder/[width]/[height]/route.ts | 51 + src/components/admin/AdminResource.tsx | 244 +++++ src/components/admin/AiContentStudio.tsx | 315 ++++++ src/components/admin/NodesTable.tsx | 22 +- src/components/admin/RenderQueueTable.tsx | 24 +- src/components/admin/admin-resources.tsx | 170 ++++ src/components/auth/AuthPageContent.tsx | 74 +- src/components/auth/SupabaseSetupNotice.tsx | 28 +- .../dashboard/DashboardEmptyState.tsx | 9 +- .../dashboard/DashboardPlanBadge.tsx | 4 +- .../dashboard/DashboardProjectsSection.tsx | 8 +- src/components/dashboard/DashboardSidebar.tsx | 8 +- .../dashboard/DashboardSidebarNav.tsx | 16 +- src/components/dashboard/DashboardTopBar.tsx | 5 +- src/components/dashboard/NewProjectMenu.tsx | 13 +- src/components/dashboard/ProjectCard.tsx | 28 +- .../dashboard/settings/SettingsBilling.tsx | 50 +- .../settings/SettingsNotifications.tsx | 34 +- .../dashboard/settings/SettingsProfile.tsx | 22 +- .../dashboard/settings/SettingsSecurity.tsx | 26 +- .../image-editor/AiRemoveBgModal.tsx | 21 +- .../image-editor/ImageCropControls.tsx | 8 +- .../image-editor/ImageEditorRightPanel.tsx | 15 +- .../image-editor/ImageEditorToolbar.tsx | 35 +- .../image-editor/ImageEditorTopBar.tsx | 20 +- .../image-editor/panels/AdjustPanel.tsx | 23 +- .../image-editor/panels/FiltersPanel.tsx | 5 +- .../image-editor/panels/LayersPanel.tsx | 11 +- .../image-maker/ImageMakerBeforeAfter.tsx | 20 +- .../image-maker/ImageMakerGallery.tsx | 14 +- src/components/layout/DirectionProvider.tsx | 19 + src/components/layout/NavbarMenuDropdown.tsx | 8 +- src/components/layout/NavbarMobileMenu.tsx | 11 +- src/components/sections/HeroPreviewCards.tsx | 58 +- .../sections/PricingAnimatedPrice.tsx | 4 +- .../sections/PricingBillingToggle.tsx | 8 +- src/components/sections/PricingCard.tsx | 4 +- .../sections/PricingCheckoutButton.tsx | 8 +- .../sections/PricingCompareTable.tsx | 9 +- .../sections/PricingCreditsBanner.tsx | 7 +- .../sections/PricingFeatureList.tsx | 7 +- src/components/sections/PricingFreeBanner.tsx | 12 +- src/components/sections/TemplateCard.tsx | 12 +- src/components/sections/TestimonialCard.tsx | 6 +- .../sections/template-gallery-data.ts | 20 +- src/components/studio/AddSceneMenu.tsx | 12 +- src/components/studio/DraggableSceneItem.tsx | 6 +- .../studio/ProjectSaveIndicator.tsx | 13 +- src/components/studio/PropertiesPanel.tsx | 8 +- src/components/studio/RenderModal.tsx | 48 +- src/components/studio/SceneBrowserCard.tsx | 4 +- src/components/studio/SceneBrowserModal.tsx | 26 +- src/components/studio/SceneItemActions.tsx | 6 +- .../studio/SceneTransitionPicker.tsx | 8 +- src/components/studio/StudioMobileGate.tsx | 13 +- src/components/studio/StudioToolbar.tsx | 28 +- .../studio/canvas/VideoLayerNode.tsx | 8 +- .../studio/properties/CommonLayerControls.tsx | 18 +- .../properties/ImageLayerProperties.tsx | 14 +- .../studio/properties/PropertyControls.tsx | 4 +- .../properties/ShapeLayerProperties.tsx | 15 +- .../studio/properties/TextLayerProperties.tsx | 32 +- .../studio/sidebar/AudioSidebarContent.tsx | 8 +- .../studio/sidebar/AudioSidebarMusicTab.tsx | 18 +- .../sidebar/AudioSidebarVoiceoverPane.tsx | 9 +- .../studio/sidebar/ColorsCustomTab.tsx | 8 +- .../studio/sidebar/ColorsPalettesTab.tsx | 8 +- .../studio/sidebar/ColorsSidebarContent.tsx | 6 +- .../sidebar/ColorsTemplatePreviewCard.tsx | 15 +- .../studio/sidebar/FontSidebarContent.tsx | 8 +- .../sidebar/SceneEditSidebarContent.tsx | 25 +- .../sidebar/TransitionsSidebarContent.tsx | 11 +- .../studio/sidebar/TtsSidebarContent.tsx | 9 +- .../sidebar/WatermarkSidebarContent.tsx | 42 +- src/components/studio/timeline/AudioTrack.tsx | 4 +- src/components/studio/timeline/SceneBlock.tsx | 4 +- .../studio/timeline/SceneThumbnailBlock.tsx | 12 +- .../studio/timeline/SceneThumbnailStrip.tsx | 6 +- src/components/studio/timeline/TimeRuler.tsx | 4 +- .../studio/timeline/TimelineActionRow.tsx | 6 +- .../studio/timeline/TimelineControlBar.tsx | 22 +- .../studio/timeline/TimelineQuickActions.tsx | 6 +- src/components/studio/video/CanvasArea.tsx | 25 +- .../studio/video/StudioSidebarDock.tsx | 31 +- src/components/studio/video/StudioTopBar.tsx | 24 +- .../studio/video/StudioTopBarSaveBadge.tsx | 16 +- .../studio/video/StudioTopBarTextControls.tsx | 14 +- .../studio/video/VideoNewPresetCard.tsx | 4 +- .../studio/video/VideoProjectNewContent.tsx | 24 +- .../templates/TemplateDetailBreadcrumb.tsx | 11 +- .../templates/TemplateDetailExamples.tsx | 6 +- .../templates/TemplateDetailInfo.tsx | 28 +- .../templates/TemplateDetailPreview.tsx | 6 +- .../templates/TemplateDetailRating.tsx | 8 +- .../templates/TemplatesActiveFilters.tsx | 11 +- src/components/templates/TemplatesSidebar.tsx | 9 +- .../video/VideoTemplateCompactCard.tsx | 8 +- .../video/VideoTemplatesCarouselRow.tsx | 8 +- .../video/VideoTemplatesCategorySidebar.tsx | 36 +- .../video/VideoTemplatesFilterControls.tsx | 11 +- .../templates/video/VideoTemplatesHero.tsx | 13 +- .../video/VideoTemplatesPageContent.tsx | 10 +- .../templates/video/VideoTemplatesToolbar.tsx | 18 +- .../trimmer/TrimmerExportSection.tsx | 14 +- src/components/trimmer/TrimmerStrip.tsx | 8 +- src/components/trimmer/TrimmerUploadZone.tsx | 6 +- .../trimmer/TrimmerVideoPreview.tsx | 18 +- src/components/ui/LogoMark.tsx | 32 +- .../video-maker/VideoMakerEditorPreview.tsx | 18 +- .../VideoMakerTemplateCarousel.tsx | 31 +- src/lib/auth/cookies.ts | 5 +- src/lib/placeholder.ts | 15 + src/lib/projects.ts | 4 +- src/lib/templates-catalog.ts | 4 +- src/lib/video-templates-catalog.ts | 8 +- src/middleware.ts | 2 +- 160 files changed, 4397 insertions(+), 767 deletions(-) create mode 100644 backend/db/migrations/17_content_ai_settings.sql create mode 100644 scripts/merge-i18n.js create mode 100644 services/content/FlatRender.ContentSvc/Application/Services/AiContentService.cs create mode 100644 services/content/FlatRender.ContentSvc/Controllers/AiController.cs create mode 100644 services/content/FlatRender.ContentSvc/Domain/Entities/AiSettings.cs create mode 100644 services/content/FlatRender.ContentSvc/Models/Ai.cs create mode 100644 src/app/[locale]/admin/ai/page.tsx create mode 100644 src/app/[locale]/admin/blogs/page.tsx create mode 100644 src/app/[locale]/admin/categories/page.tsx create mode 100644 src/app/[locale]/admin/fonts/page.tsx create mode 100644 src/app/[locale]/admin/plans/page.tsx create mode 100644 src/app/[locale]/admin/slides/page.tsx create mode 100644 src/app/[locale]/admin/tags/page.tsx create mode 100644 src/app/[locale]/admin/users/page.tsx create mode 100644 src/app/api/admin/ai/_aiProxy.ts create mode 100644 src/app/api/admin/ai/generate/route.ts create mode 100644 src/app/api/admin/ai/save/route.ts create mode 100644 src/app/api/admin/ai/settings/route.ts create mode 100644 src/app/api/admin/resource/[...path]/route.ts create mode 100644 src/app/api/placeholder/[width]/[height]/route.ts create mode 100644 src/components/admin/AdminResource.tsx create mode 100644 src/components/admin/AiContentStudio.tsx create mode 100644 src/components/admin/admin-resources.tsx create mode 100644 src/components/layout/DirectionProvider.tsx create mode 100644 src/lib/placeholder.ts diff --git a/.env.example b/.env.example index 41b9e5b..b06e990 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,9 @@ API_GATEWAY_URL=http://localhost:8088 NEXT_PUBLIC_API_URL=http://localhost:8088/v1 # Tenant the public site authenticates against (Identity service). 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) # Run the admin-api service at D:\Projects\flatrender-admin\admin-api diff --git a/backend/db/migrations/17_content_ai_settings.sql b/backend/db/migrations/17_content_ai_settings.sql new file mode 100644 index 0000000..1ea24e4 --- /dev/null +++ b/backend/db/migrations/17_content_ai_settings.sql @@ -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() +); diff --git a/docker-compose.v2.yml b/docker-compose.v2.yml index e892dc6..e93c577 100644 --- a/docker-compose.v2.yml +++ b/docker-compose.v2.yml @@ -107,7 +107,7 @@ services: 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" Jwt__Secret: "${JWT_SECRET}" - Jwt__Issuer: "flatrender" + Jwt__Issuer: "flatrender-identity" Jwt__Audience: "flatrender" depends_on: postgres: @@ -159,7 +159,7 @@ services: 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" Jwt__Key: "${JWT_SECRET}" - Jwt__Issuer: "flatrender" + Jwt__Issuer: "flatrender-identity" Jwt__Audience: "flatrender" Cors__Origins__0: "${CORS_ORIGIN:-http://localhost:3000}" depends_on: diff --git a/messages/en.json b/messages/en.json index 16b6516..4b8fae1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -305,5 +305,905 @@ "all": "All", "popular": "Popular", "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 and Privacy Policy." + }, + "componentsAuthSupabaseSetupNotice": { + "title": "Supabase not configured", + "instructions": "Copy to and set and , 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 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." + } } } diff --git a/messages/fa.json b/messages/fa.json index 0d70cef..f99ca1f 100644 --- a/messages/fa.json +++ b/messages/fa.json @@ -305,5 +305,905 @@ "all": "همه", "popular": "محبوب", "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": "با ادامه دادن، با قوانین و سیاست حفظ حریم خصوصی ما موافقت می‌کنید." + }, + "componentsAuthSupabaseSetupNotice": { + "title": "Supabase پیکربندی نشده است", + "instructions": "فایل را به کپی کنید و مقادیر و را تنظیم کنید، سپس سرور توسعه را دوباره راه‌اندازی کنید.", + "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": "شما در حالت ویرایش هستید — ممکن است ظاهر متفاوت به نظر برسد. برای دیدن نتیجه نهایی روی پیش‌نمایش بزنید." + }, + "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 را در بالا پیکربندی و فعال کنید." + } } } diff --git a/next.config.mjs b/next.config.mjs index 7b844c0..cf19c4e 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -25,12 +25,12 @@ const nextConfig = { return config; }, images: { - remotePatterns: [ - { - protocol: "https", - hostname: "picsum.photos", - }, - ], + // Placeholder art is now a same-origin SVG from /api/placeholder (offline-safe). + // dangerouslyAllowSVG only ever serves our own generated gradients — never user + // uploads — and the CSP + attachment disposition neutralise any script content. + dangerouslyAllowSVG: true, + contentDispositionType: "attachment", + contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;", }, // Required for ffmpeg.wasm (SharedArrayBuffer needs COOP + COEP headers) async headers() { diff --git a/public/favicon.svg b/public/favicon.svg index fe51466..ca1ef41 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1,7 +1,7 @@ - - - - - - + + + + + + diff --git a/scripts/merge-i18n.js b/scripts/merge-i18n.js new file mode 100644 index 0000000..c00b79f --- /dev/null +++ b/scripts/merge-i18n.js @@ -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 "); 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)); diff --git a/services/content/FlatRender.ContentSvc/Application/Services/AiContentService.cs b/services/content/FlatRender.ContentSvc/Application/Services/AiContentService.cs new file mode 100644 index 0000000..4406339 --- /dev/null +++ b/services/content/FlatRender.ContentSvc/Application/Services/AiContentService.cs @@ -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; + +/// Thrown for expected/config errors (missing key, provider error) → mapped to 400 by the controller. +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 GetRawAsync(Guid tenantId) + { + return await db.AiSettings.FirstOrDefaultAsync(x => x.TenantId == tenantId) + ?? new AiSettings { TenantId = tenantId }; + } + + public async Task 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 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 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

,

,

,

  • , (no //

    ), 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] + "…"; +} diff --git a/services/content/FlatRender.ContentSvc/Controllers/AiController.cs b/services/content/FlatRender.ContentSvc/Controllers/AiController.cs new file mode 100644 index 0000000..82e96cb --- /dev/null +++ b/services/content/FlatRender.ContentSvc/Controllers/AiController.cs @@ -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 GetSettings() + { + if (!IsAdmin) return Forbidden(); + return Ok(await svc.GetSettingsAsync(TenantId)); + } + + [HttpPut("settings")] + public async Task UpdateSettings([FromBody] UpdateAiSettingsRequest req) + { + if (!IsAdmin) return Forbidden(); + return Ok(await svc.UpdateSettingsAsync(TenantId, req)); + } + + [HttpPost("seo-post")] + public async Task 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." } }); +} diff --git a/services/content/FlatRender.ContentSvc/Domain/Entities/AiSettings.cs b/services/content/FlatRender.ContentSvc/Domain/Entities/AiSettings.cs new file mode 100644 index 0000000..589ae30 --- /dev/null +++ b/services/content/FlatRender.ContentSvc/Domain/Entities/AiSettings.cs @@ -0,0 +1,13 @@ +namespace FlatRender.ContentSvc.Domain.Entities; + +/// Per-tenant OpenAI (or OpenAI-compatible) configuration for the AI content generator. +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; } +} diff --git a/services/content/FlatRender.ContentSvc/Infrastructure/Data/ContentDbContext.cs b/services/content/FlatRender.ContentSvc/Infrastructure/Data/ContentDbContext.cs index 5d33929..05d8691 100644 --- a/services/content/FlatRender.ContentSvc/Infrastructure/Data/ContentDbContext.cs +++ b/services/content/FlatRender.ContentSvc/Infrastructure/Data/ContentDbContext.cs @@ -58,6 +58,9 @@ public class ContentDbContext(DbContextOptions options) : DbCo public DbSet FavoriteFolders => Set(); public DbSet FavoriteContainers => Set(); + // AI + public DbSet AiSettings => Set(); + protected override void OnModelCreating(ModelBuilder mb) { mb.HasDefaultSchema("content"); @@ -70,6 +73,13 @@ public class ContentDbContext(DbContextOptions options) : DbCo ConfigureScenes(mb); ConfigureCharacters(mb); ConfigureCms(mb); + + // AI settings — snake_case convention maps columns (tenant_id, api_key, …). + mb.Entity(e => + { + e.ToTable("ai_settings"); + e.HasKey(x => x.TenantId); + }); } private static void ConfigureTaxonomy(ModelBuilder mb) diff --git a/services/content/FlatRender.ContentSvc/Models/Ai.cs b/services/content/FlatRender.ContentSvc/Models/Ai.cs new file mode 100644 index 0000000..03a1df7 --- /dev/null +++ b/services/content/FlatRender.ContentSvc/Models/Ai.cs @@ -0,0 +1,44 @@ +namespace FlatRender.ContentSvc.Models; + +// ── AI settings ─────────────────────────────────────────────────────────────── + +/// Settings returned to the admin UI. The API key is never returned in full. +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 +); diff --git a/services/content/FlatRender.ContentSvc/Program.cs b/services/content/FlatRender.ContentSvc/Program.cs index c3d8fcc..40b00bc 100644 --- a/services/content/FlatRender.ContentSvc/Program.cs +++ b/services/content/FlatRender.ContentSvc/Program.cs @@ -50,7 +50,9 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) ValidIssuer = builder.Configuration["Jwt:Issuer"], ValidAudience = builder.Configuration["Jwt:Audience"], 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(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// HTTP client for the OpenAI-compatible AI provider (base URL is per-tenant config). +builder.Services.AddHttpClient("openai"); // ── HTTP ────────────────────────────────────────────────────────────────────── diff --git a/services/gateway/cmd/server/main.go b/services/gateway/cmd/server/main.go index 827a6a9..ebb3377 100644 --- a/services/gateway/cmd/server/main.go +++ b/services/gateway/cmd/server/main.go @@ -115,6 +115,7 @@ func main() { v1.Any("/settings/*path", apiRL, optionalAuth, content.Handler()) v1.Any("/comments/*path", apiRL, auth, content.Handler()) v1.Any("/favorites/*path", apiRL, auth, content.Handler()) + v1.Any("/ai/*path", apiRL, auth, content.Handler()) // ── File Service ───────────────────────────────────────────────────────── v1.Any("/files/*path", apiRL, auth, file.Handler()) diff --git a/services/identity/FlatRender.IdentitySvc/Application/Services/TokenService.cs b/services/identity/FlatRender.IdentitySvc/Application/Services/TokenService.cs index d6e3634..0f60107 100644 --- a/services/identity/FlatRender.IdentitySvc/Application/Services/TokenService.cs +++ b/services/identity/FlatRender.IdentitySvc/Application/Services/TokenService.cs @@ -21,6 +21,9 @@ public class TokenService(IConfiguration config) : ITokenService var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret)); 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 { new(JwtRegisteredClaimNames.Sub, user.Id.ToString()), @@ -29,6 +32,7 @@ public class TokenService(IConfiguration config) : ITokenService new("tenant_slug", tenant.Slug), new("is_admin", user.IsAdmin.ToString().ToLower()), new("is_tenant_admin", user.IsTenantAdmin.ToString().ToLower()), + new("role", role), }; if (!string.IsNullOrEmpty(user.Email)) diff --git a/services/studio/FlatRender.StudioSvc/Program.cs b/services/studio/FlatRender.StudioSvc/Program.cs index 7ce09a6..5f9e71b 100644 --- a/services/studio/FlatRender.StudioSvc/Program.cs +++ b/services/studio/FlatRender.StudioSvc/Program.cs @@ -39,7 +39,7 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) ValidateAudience = true, ValidAudience = builder.Configuration["Jwt:Audience"], ValidateLifetime = true, - ClockSkew = TimeSpan.FromSeconds(30) + ClockSkew = TimeSpan.FromSeconds(30), }; }); diff --git a/src/app/[locale]/admin/ai/page.tsx b/src/app/[locale]/admin/ai/page.tsx new file mode 100644 index 0000000..a734690 --- /dev/null +++ b/src/app/[locale]/admin/ai/page.tsx @@ -0,0 +1,7 @@ +import { AiContentStudio } from "@/components/admin/AiContentStudio"; + +export const dynamic = "force-dynamic"; + +export default function AdminAiPage() { + return ; +} diff --git a/src/app/[locale]/admin/blogs/page.tsx b/src/app/[locale]/admin/blogs/page.tsx new file mode 100644 index 0000000..829ac37 --- /dev/null +++ b/src/app/[locale]/admin/blogs/page.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { AdminResource } from "@/components/admin/AdminResource"; +import { blogsConfig } from "@/components/admin/admin-resources"; + +export default function Page() { + return ; +} diff --git a/src/app/[locale]/admin/categories/page.tsx b/src/app/[locale]/admin/categories/page.tsx new file mode 100644 index 0000000..5ab5932 --- /dev/null +++ b/src/app/[locale]/admin/categories/page.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { AdminResource } from "@/components/admin/AdminResource"; +import { categoriesConfig } from "@/components/admin/admin-resources"; + +export default function Page() { + return ; +} diff --git a/src/app/[locale]/admin/fonts/page.tsx b/src/app/[locale]/admin/fonts/page.tsx new file mode 100644 index 0000000..5a3e750 --- /dev/null +++ b/src/app/[locale]/admin/fonts/page.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { AdminResource } from "@/components/admin/AdminResource"; +import { fontsConfig } from "@/components/admin/admin-resources"; + +export default function Page() { + return ; +} diff --git a/src/app/[locale]/admin/layout.tsx b/src/app/[locale]/admin/layout.tsx index 3681a9f..ce36141 100644 --- a/src/app/[locale]/admin/layout.tsx +++ b/src/app/[locale]/admin/layout.tsx @@ -1,4 +1,5 @@ import { redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; import { getCurrentUser } from "@/lib/auth/session"; @@ -13,28 +14,38 @@ export default async function AdminLayout({ 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 (
    diff --git a/src/app/[locale]/admin/nodes/page.tsx b/src/app/[locale]/admin/nodes/page.tsx index c58944a..4ed0077 100644 --- a/src/app/[locale]/admin/nodes/page.tsx +++ b/src/app/[locale]/admin/nodes/page.tsx @@ -1,3 +1,5 @@ +import { getTranslations } from "next-intl/server"; + import { adminGet } from "@/lib/api/admin-gateway"; import { NodesTable } from "@/components/admin/NodesTable"; @@ -24,14 +26,15 @@ interface V2NodeList { export default async function AdminNodesPage() { const data = await adminGet("/v1/nodes?pageSize=100"); const nodes = data?.items ?? []; + const t = await getTranslations("auto.appAdminNodesPage"); return (
    -

    Render Nodes

    +

    {t("title")}

    - {nodes.length} node{nodes.length !== 1 ? "s" : ""} registered + {t("registered", { count: nodes.length })}

    diff --git a/src/app/[locale]/admin/plans/page.tsx b/src/app/[locale]/admin/plans/page.tsx new file mode 100644 index 0000000..5d14b8a --- /dev/null +++ b/src/app/[locale]/admin/plans/page.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { AdminResource } from "@/components/admin/AdminResource"; +import { plansConfig } from "@/components/admin/admin-resources"; + +export default function Page() { + return ; +} diff --git a/src/app/[locale]/admin/renders/page.tsx b/src/app/[locale]/admin/renders/page.tsx index 67a2885..ebb6d73 100644 --- a/src/app/[locale]/admin/renders/page.tsx +++ b/src/app/[locale]/admin/renders/page.tsx @@ -1,3 +1,5 @@ +import { getTranslations } from "next-intl/server"; + import { adminGet } from "@/lib/api/admin-gateway"; import { RenderQueueTable } from "@/components/admin/RenderQueueTable"; @@ -35,15 +37,25 @@ export default async function AdminRendersPage({ const data = await adminGet(`/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 = { + Queued: t("stepQueued"), + Preparing: t("stepPreparing"), + Rendering: t("stepRendering"), + Uploading: t("stepUploading"), + Done: t("stepDone"), + Failed: t("stepFailed"), + Cancelled: t("stepCancelled"), + }; return (
    -

    Render Queue

    -

    {total} total jobs

    +

    {t("title")}

    +

    {t("totalJobs", { total })}

    @@ -57,7 +69,7 @@ export default async function AdminRendersPage({ : "border-[#1e2235] text-gray-400 hover:text-white hover:border-[#2a3050]" }`} > - All + {t("filterAll")} {steps.map((s) => ( - {s} + {stepLabels[s] ?? s} ))}
    diff --git a/src/app/[locale]/admin/slides/page.tsx b/src/app/[locale]/admin/slides/page.tsx new file mode 100644 index 0000000..2d22bb8 --- /dev/null +++ b/src/app/[locale]/admin/slides/page.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { AdminResource } from "@/components/admin/AdminResource"; +import { slidesConfig } from "@/components/admin/admin-resources"; + +export default function Page() { + return ; +} diff --git a/src/app/[locale]/admin/tags/page.tsx b/src/app/[locale]/admin/tags/page.tsx new file mode 100644 index 0000000..e190f94 --- /dev/null +++ b/src/app/[locale]/admin/tags/page.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { AdminResource } from "@/components/admin/AdminResource"; +import { tagsConfig } from "@/components/admin/admin-resources"; + +export default function Page() { + return ; +} diff --git a/src/app/[locale]/admin/users/page.tsx b/src/app/[locale]/admin/users/page.tsx new file mode 100644 index 0000000..c49492a --- /dev/null +++ b/src/app/[locale]/admin/users/page.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { AdminResource } from "@/components/admin/AdminResource"; +import { usersConfig } from "@/components/admin/admin-resources"; + +export default function Page() { + return ; +} diff --git a/src/app/[locale]/auth/page.tsx b/src/app/[locale]/auth/page.tsx index 06ec103..188ed7d 100644 --- a/src/app/[locale]/auth/page.tsx +++ b/src/app/[locale]/auth/page.tsx @@ -1,23 +1,28 @@ import type { Metadata } from "next"; import { Suspense } from "react"; +import { getTranslations } from "next-intl/server"; import { AuthLoadingSpinner } from "@/components/auth/AuthLoadingSpinner"; import { AuthPageContent } from "@/components/auth/AuthPageContent"; import { createPageMetadata } from "@/lib/metadata"; -export const metadata: Metadata = createPageMetadata({ - title: "Sign In", - description: "Sign in or create your CreatorStudio account.", - path: "/auth", -}); +export async function generateMetadata(): Promise { + const t = await getTranslations("auto.appAuthPage"); + return createPageMetadata({ + title: t("metaTitle"), + description: t("metaDescription"), + path: "/auth", + }); +} -export default function AuthPage() { +export default async function AuthPage() { + const t = await getTranslations("auto.appAuthPage"); return (
    - +
    } > diff --git a/src/app/[locale]/dashboard/settings/page.tsx b/src/app/[locale]/dashboard/settings/page.tsx index 603f9b1..49e5528 100644 --- a/src/app/[locale]/dashboard/settings/page.tsx +++ b/src/app/[locale]/dashboard/settings/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { SettingsBilling } from "@/components/dashboard/settings/SettingsBilling"; import { SettingsNotifications } from "@/components/dashboard/settings/SettingsNotifications"; @@ -17,6 +18,7 @@ export const metadata: Metadata = createPageMetadata({ export const dynamic = "force-dynamic"; export default async function DashboardSettingsPage() { + const t = await getTranslations("auto.appDashboardSettingsPage"); // Auth is served by the V2 Identity service (JWT cookie), not Supabase. const user = await getCurrentUser(); @@ -31,9 +33,9 @@ export default async function DashboardSettingsPage() {
    {/* Page header */}
    -

    Settings

    +

    {t("title")}

    - Manage your account, security, and notification preferences. + {t("subtitle")}

    @@ -47,15 +49,15 @@ export default async function DashboardSettingsPage() { {/* Danger zone */}
    -

    Danger zone

    +

    {t("dangerZoneTitle")}

    - Permanently delete your account and all your projects. This cannot be undone. + {t("dangerZoneDescription")}

    diff --git a/src/app/[locale]/error.tsx b/src/app/[locale]/error.tsx index 2ad27eb..812c4c7 100644 --- a/src/app/[locale]/error.tsx +++ b/src/app/[locale]/error.tsx @@ -1,5 +1,6 @@ "use client"; +import { useTranslations } from "next-intl"; import { useEffect } from "react"; import { Button } from "@/components/ui/button"; @@ -10,6 +11,8 @@ interface ErrorPageProps { } export default function ErrorPage({ error, reset }: ErrorPageProps) { + const t = useTranslations("auto.appError"); + useEffect(() => { // Surface to monitoring in production when configured }, [error]); @@ -17,17 +20,17 @@ export default function ErrorPage({ error, reset }: ErrorPageProps) { return (

    - Something went wrong + {t("title")}

    - An unexpected error occurred. Try reloading the page. + {t("description")}

    ); diff --git a/src/app/[locale]/image-maker/page.tsx b/src/app/[locale]/image-maker/page.tsx index 1307e79..2702707 100644 --- a/src/app/[locale]/image-maker/page.tsx +++ b/src/app/[locale]/image-maker/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { ImageMakerCta } from "@/components/image-maker/ImageMakerCta"; 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 { createPageMetadata } from "@/lib/metadata"; -export const metadata: Metadata = createPageMetadata({ - title: "AI Image Maker", - description: - "Design professional visuals instantly with AI generation, templates, brand kits, and batch export.", - path: "/image-maker", -}); +export async function generateMetadata(): Promise { + const t = await getTranslations("auto.appImageMakerPage"); + return createPageMetadata({ + title: t("metaTitle"), + description: t("metaDescription"), + path: "/image-maker", + }); +} export default function ImageMakerPage() { return ( diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index f13f7f6..9414517 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -4,6 +4,7 @@ import { notFound } from "next/navigation"; import { getMessages, getTranslations } from "next-intl/server"; import { NextIntlClientProvider } from "next-intl"; +import { DirectionProvider } from "@/components/layout/DirectionProvider"; import { SiteChrome } from "@/components/layout/SiteChrome"; import { routing } from "@/i18n/routing"; import type { Locale } from "@/i18n/routing"; @@ -102,7 +103,6 @@ export default async function LocaleLayout({ > - - {children} + + {children} + diff --git a/src/app/[locale]/not-found.tsx b/src/app/[locale]/not-found.tsx index 8f372cd..59956a8 100644 --- a/src/app/[locale]/not-found.tsx +++ b/src/app/[locale]/not-found.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import Link from "next/link"; import { Button } from "@/components/ui/button"; @@ -10,17 +11,19 @@ export const metadata: Metadata = createPageMetadata({ path: "/404", }); -export default function NotFoundPage() { +export default async function NotFoundPage() { + const t = await getTranslations("auto.appNotFound"); + return (

    - Page not found + {t("title")}

    - The page you are looking for does not exist or may have been moved. + {t("description")}

    ); diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx index 18a34d7..7b492e4 100644 --- a/src/app/[locale]/page.tsx +++ b/src/app/[locale]/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { Hero } from "@/components/sections/Hero"; import { HowItWorks } from "@/components/sections/HowItWorks"; @@ -10,12 +11,14 @@ import { Testimonials } from "@/components/sections/Testimonials"; import { createPageMetadata } from "@/lib/metadata"; import { fetchProjects } from "@/lib/admin-api"; -export const metadata: Metadata = createPageMetadata({ - title: "Create Pro Videos & Images with AI", - description: - "FlatRender helps creators and brands make professional videos and images with AI templates, editors, and one-click export.", - path: "/", -}); +export async function generateMetadata(): Promise { + const t = await getTranslations("auto.appPage"); + return createPageMetadata({ + title: t("metaTitle"), + description: t("metaDescription"), + path: "/", + }); +} export default async function Home() { // Fetch up to 8 published projects from the admin service. diff --git a/src/app/[locale]/studio/image/[projectId]/page.tsx b/src/app/[locale]/studio/image/[projectId]/page.tsx index d855fb4..9afa202 100644 --- a/src/app/[locale]/studio/image/[projectId]/page.tsx +++ b/src/app/[locale]/studio/image/[projectId]/page.tsx @@ -1,6 +1,7 @@ "use client"; import dynamic from "next/dynamic"; +import { useTranslations } from "next-intl"; const ImageEditorLayout = dynamic( () => @@ -9,14 +10,19 @@ const ImageEditorLayout = dynamic( ), { ssr: false, - loading: () => ( -
    - Loading editor… -
    - ), + loading: () => , } ); +function ImageEditorLoading() { + const t = useTranslations("auto.appStudioImageProjectIdPage"); + return ( +
    + {t("loadingEditor")} +
    + ); +} + interface ImageStudioPageProps { params: { projectId: string; diff --git a/src/app/[locale]/studio/trimmer/page.tsx b/src/app/[locale]/studio/trimmer/page.tsx index 7447fac..4224c11 100644 --- a/src/app/[locale]/studio/trimmer/page.tsx +++ b/src/app/[locale]/studio/trimmer/page.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; +import { useTranslations } from "next-intl"; import { ArrowLeft, Scissors } from "lucide-react"; 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 }; export default function VideoTrimmerPage() { + const t = useTranslations("auto.appStudioTrimmerPage"); const [uploadedFile, setUploadedFile] = useState(null); const [videoUrl, setVideoUrl] = useState(null); const [duration, setDuration] = useState(0); @@ -47,16 +49,14 @@ export default function VideoTrimmerPage() { }) .catch(() => { if (!cancelled) { - setFfmpegError( - "Failed to load FFmpeg. Check your connection and try again." - ); + setFfmpegError(t("ffmpegLoadError")); } }); return () => { cancelled = true; }; - }, []); + }, [t]); useEffect(() => { return () => { @@ -130,7 +130,7 @@ export default function VideoTrimmerPage() { setOutputUrl(URL.createObjectURL(blob)); } catch { - setFfmpegError("Processing failed. Try a shorter clip or different format."); + setFfmpegError(t("processingError")); } finally { setIsProcessing(false); } @@ -144,6 +144,7 @@ export default function VideoTrimmerPage() { videoSize, exportFormat, outputUrl, + t, ]); 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" > - Back + {t("back")}

    - Video Trimmer & Cropper + {t("title")}

    diff --git a/src/app/[locale]/studio/video/[projectId]/page.tsx b/src/app/[locale]/studio/video/[projectId]/page.tsx index a28abf2..e01da59 100644 --- a/src/app/[locale]/studio/video/[projectId]/page.tsx +++ b/src/app/[locale]/studio/video/[projectId]/page.tsx @@ -1,6 +1,7 @@ "use client"; import dynamic from "next/dynamic"; +import { useTranslations } from "next-intl"; const VideoStudioLayout = dynamic( () => @@ -9,14 +10,19 @@ const VideoStudioLayout = dynamic( ), { ssr: false, - loading: () => ( -
    - Loading studio… -
    - ), + loading: () => , } ); +function VideoStudioLoading() { + const t = useTranslations("auto.appStudioVideoProjectIdPage"); + return ( +
    + {t("loading")} +
    + ); +} + interface VideoStudioPageProps { params: { projectId: string; diff --git a/src/app/[locale]/video-maker/page.tsx b/src/app/[locale]/video-maker/page.tsx index 707b56e..476fc6f 100644 --- a/src/app/[locale]/video-maker/page.tsx +++ b/src/app/[locale]/video-maker/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { VideoMakerCta } from "@/components/video-maker/VideoMakerCta"; 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 { createPageMetadata } from "@/lib/metadata"; -export const metadata: Metadata = createPageMetadata({ - title: "AI Video Maker", - description: - "Create stunning videos in minutes with AI scripts, auto-subtitles, 500+ templates, and 1-click export.", - path: "/video-maker", -}); +export async function generateMetadata(): Promise { + const t = await getTranslations("auto.appVideoMakerPage"); + return createPageMetadata({ + title: t("metaTitle"), + description: t("metaDescription"), + path: "/video-maker", + }); +} export default function VideoMakerPage() { return ( diff --git a/src/app/api/admin/ai/_aiProxy.ts b/src/app/api/admin/ai/_aiProxy.ts new file mode 100644 index 0000000..bf50590 --- /dev/null +++ b/src/app/api/admin/ai/_aiProxy.ts @@ -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 { + 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 }); +} diff --git a/src/app/api/admin/ai/generate/route.ts b/src/app/api/admin/ai/generate/route.ts new file mode 100644 index 0000000..2a0b747 --- /dev/null +++ b/src/app/api/admin/ai/generate/route.ts @@ -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"); +} diff --git a/src/app/api/admin/ai/save/route.ts b/src/app/api/admin/ai/save/route.ts new file mode 100644 index 0000000..bb9b490 --- /dev/null +++ b/src/app/api/admin/ai/save/route.ts @@ -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"); +} diff --git a/src/app/api/admin/ai/settings/route.ts b/src/app/api/admin/ai/settings/route.ts new file mode 100644 index 0000000..134cad5 --- /dev/null +++ b/src/app/api/admin/ai/settings/route.ts @@ -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"); +} diff --git a/src/app/api/admin/resource/[...path]/route.ts b/src/app/api/admin/resource/[...path]/route.ts new file mode 100644 index 0000000..bf0d05a --- /dev/null +++ b/src/app/api/admin/resource/[...path]/route.ts @@ -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/, attaching the admin's bearer token. Admin-gated server-side. + * + * /api/admin/resource/categories → /v1/categories + * /api/admin/resource/categories/ → /v1/categories/ + * /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 { + 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"); +} diff --git a/src/app/api/placeholder/[width]/[height]/route.ts b/src/app/api/placeholder/[width]/[height]/route.ts new file mode 100644 index 0000000..b6dc154 --- /dev/null +++ b/src/app/api/placeholder/[width]/[height]/route.ts @@ -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 = ` + + + + + + + +`; + + return new Response(svg, { + headers: { + "Content-Type": "image/svg+xml", + "Cache-Control": "public, max-age=31536000, immutable", + }, + }); +} diff --git a/src/components/admin/AdminResource.tsx b/src/components/admin/AdminResource.tsx new file mode 100644 index 0000000..52fa866 --- /dev/null +++ b/src/components/admin/AdminResource.tsx @@ -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) => 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, 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[]>([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [editing, setEditing] = useState | null>(null); + const [creating, setCreating] = useState(false); + const [form, setForm] = useState>({}); + 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 = {}; + config.fields?.forEach((f) => (init[f.key] = f.defaultValue ?? (f.type === "checkbox" ? false : ""))); + setForm(init); + setCreating(true); + setEditing(null); + }; + + const openEdit = (row: Record) => { + const init: Record = {}; + 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) => { + 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 ( +
    +
    +
    +

    {config.title}

    + {config.description &&

    {config.description}

    } +
    + {config.canCreate && config.fields && ( + + )} +
    + + {error &&

    {error}

    } + +
    + + + + {config.columns.map((c) => ( + + ))} + {(config.canEdit || config.canDelete || config.rowActions) && ( + + )} + + + + {loading ? ( + + ) : rows.length === 0 ? ( + + ) : ( + rows.map((row, i) => ( + + {config.columns.map((c) => ( + + ))} + {(config.canEdit || config.canDelete || config.rowActions) && ( + + )} + + )) + )} + +
    {c.label}Actions
    Loading…
    No records.
    + {c.render ? c.render(row) : formatCell(row[c.key])} + +
    + {config.rowActions?.(row, reload)} + {config.canEdit && config.fields && ( + + )} + {config.canDelete && ( + + )} +
    +
    +
    + + {(creating || editing) && config.fields && ( +
    +
    e.stopPropagation()}> +

    + {editing ? "Edit" : "New"} {config.title.replace(/s$/, "")} +

    +
    + {config.fields.map((f) => ( +
    + {f.type !== "checkbox" && ( + + )} + {f.type === "textarea" ? ( +