feat: AI SEO generator, full admin panel, i18n sweep, new logo + auth/RTL fixes
Build backend images / build content-svc (push) Failing after 3m39s
Build backend images / build file-svc (push) Failing after 52s
Build backend images / build gateway (push) Failing after 58s
Build backend images / build identity-svc (push) Failing after 1m21s
Build backend images / build notification-svc (push) Failing after 1m0s
Build backend images / build render-svc (push) Failing after 58s
Build backend images / build studio-svc (push) Failing after 55s

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 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-02 09:35:14 +03:30
parent bcc69f0a2e
commit 3fc7bf2b97
160 changed files with 4397 additions and 767 deletions
+3
View File
@@ -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
@@ -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()
);
+2 -2
View File
@@ -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:
+900
View File
@@ -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>Terms</terms> and <privacy>Privacy Policy</privacy>."
},
"componentsAuthSupabaseSetupNotice": {
"title": "Supabase not configured",
"instructions": "Copy <envExample></envExample> to <envLocal></envLocal> and set <supabaseUrl></supabaseUrl> and <supabaseAnonKey></supabaseAnonKey>, then restart the dev server.",
"continueDev": "Continue without signing in (dev only)",
"backToHome": "Back to home"
},
"componentsDashboardDashboardEmptyState": {
"title": "No projects yet",
"description": "Create a video, image, or trim project to see it here. Everything you save appears in this workspace.",
"createFirstProject": "Create your first project"
},
"componentsDashboardDashboardPlanBadge": {
"upgradePlan": "Upgrade plan"
},
"componentsDashboardDashboardProjectsSection": {
"recentProjects": "Recent Projects",
"noResultsTitle": "No projects match your search",
"noResultsDescription": "Try a different keyword or clear the search bar."
},
"componentsDashboardSettingsSettingsBilling": {
"title": "Billing & Plan",
"subtitle": "Manage your subscription and payment method.",
"currentPlan": "Current plan",
"planFree": "Free",
"planPro": "Pro",
"planBusiness": "Business",
"statusCancelsAtPeriodEnd": "Cancels at period end",
"statusActive": "Active",
"statusFreeTier": "Free tier",
"upgrade": "Upgrade",
"changePlan": "Change plan",
"cancelPlan": "Cancel plan",
"cancelling": "Cancelling…",
"cancelConfirm": "Cancel your plan? You'll keep access until the current period ends.",
"cancelFailed": "Failed to cancel plan. Please try again.",
"networkError": "Network error. Please try again.",
"cancelledNotice": "Your plan has been cancelled. You'll keep access until the end of your billing period.",
"upgradeHint": "Upgrade to unlock unlimited projects, 4K export, and premium templates.",
"featureFree5Projects": "5 projects",
"featureFree720pExport": "720p export",
"featureFreeCommunityTemplates": "Community templates",
"featureProUnlimitedProjects": "Unlimited projects",
"featurePro4kExport": "4K export",
"featureProAllTemplates": "All templates",
"featureProPriorityRenderQueue": "Priority render queue",
"featureProCustomFonts": "Custom fonts",
"featureBusinessEverythingInPro": "Everything in Pro",
"featureBusinessTeamSeats": "Team seats",
"featureBusinessWhiteLabelExport": "White-label export",
"featureBusinessApiAccess": "API access",
"featureBusinessDedicatedSupport": "Dedicated support"
},
"componentsDashboardSettingsSettingsNotifications": {
"title": "Notifications",
"subtitle": "Choose which emails you receive from FlatRender.",
"savePreferences": "Save preferences",
"saved": "Saved!",
"renderCompleteLabel": "Render complete",
"renderCompleteDescription": "Get notified when your video export finishes.",
"projectSharedLabel": "Project shared with you",
"projectSharedDescription": "When a team member shares a project.",
"weeklyDigestLabel": "Weekly digest",
"weeklyDigestDescription": "Summary of new templates and platform updates.",
"productNewsLabel": "Product news",
"productNewsDescription": "New features, tips, and announcements."
},
"componentsDashboardSettingsSettingsProfile": {
"title": "Profile",
"subtitle": "Your public name and account email.",
"displayNameLabel": "Display name",
"displayNamePlaceholder": "Your name",
"emailLabel": "Email",
"emailHint": "Email cannot be changed here. Contact support.",
"saving": "Saving…",
"saveChanges": "Save changes",
"updateFailed": "Could not update profile.",
"updateSuccess": "Profile updated successfully.",
"networkError": "Network error. Please try again."
},
"componentsDashboardSettingsSettingsSecurity": {
"title": "Security",
"subtitle": "Change your account password.",
"currentPasswordLabel": "Current password",
"newPasswordLabel": "New password",
"confirmPasswordLabel": "Confirm new password",
"showPassword": "Show password",
"hidePassword": "Hide password",
"saving": "Saving…",
"changePassword": "Change password",
"errorMinLength": "New password must be at least 8 characters.",
"errorMismatch": "Passwords do not match.",
"errorChangeFailed": "Could not change password.",
"changeSuccess": "Password changed successfully.",
"networkError": "Network error. Please try again."
},
"componentsImageMakerImageMakerBeforeAfter": {
"beforeAlt": "Before editing",
"afterAlt": "After editing with AI",
"beforeLabel": "Before",
"afterLabel": "After",
"caption": "AI-enhanced color, layout, and brand styling applied in one click"
},
"componentsImageMakerImageMakerGallery": {
"title": "Example outputs from creators",
"subtitle": "Real-world layouts and styles you can recreate—or use as inspiration for your next project."
},
"componentsLayoutNavbarMenuDropdown": {
"learn": "Learn"
},
"componentsLayoutNavbarMobileMenu": {
"videoMaker": "Video Maker",
"imageMaker": "Image Maker",
"pricing": "Pricing",
"learn": "Learn"
},
"componentsSectionsHeroPreviewCards": {
"heading": "Made by world-class motion designers",
"previewAriaLabel": "{label} preview",
"template3dTitle": "Factory of 3D Animations",
"templateWhiteboardTitle": "Whiteboard Animation Toolkit",
"templateExplainerTitle": "3D Explainer Video Toolkit",
"templateTrendyTitle": "Trendy Explainer Toolkit"
},
"componentsSectionsPricingAnimatedPrice": {
"perMonth": "/ month"
},
"componentsSectionsPricingBillingToggle": {
"monthly": "Monthly",
"yearly": "Yearly",
"savePercent": "Save {percent}%",
"switchToYearly": "Switch to Yearly to save more"
},
"componentsSectionsPricingCard": {
"mostPopular": "Most Popular"
},
"componentsTemplatesTemplateDetailExamples": {
"heading": "Videos created using this template"
},
"componentsTemplatesTemplateDetailInfo": {
"sceneCount": "{count} scenes",
"durationFlexible": "Flexible",
"durationFixed": "Fixed",
"fallbackDescription": "Create stunning videos with this professional template. Choose scenes, customize text, and export in minutes.",
"availableStyles": "Available styles ({count})",
"styleClassic": "Classic",
"styleModern": "Modern",
"styleBold": "Bold",
"styleMinimal": "Minimal",
"createNow": "Create Now",
"removeFromFavorites": "Remove from favorites",
"addToFavorites": "Add to favorites",
"createError": "Could not create project: {error}"
},
"componentsTemplatesTemplateDetailPreview": {
"posterAlt": "{name} preview",
"playPreview": "Play template preview"
},
"componentsTemplatesTemplateDetailRating": {
"starsAriaLabel": "{score} out of 5 stars",
"ratingsCount": "({count} Ratings)"
},
"componentsTemplatesTemplatesActiveFilters": {
"removeFilter": "Remove filter: {label}",
"searchLabel": "Search: \"{query}\""
},
"componentsTemplatesVideoVideoTemplatesHero": {
"breadcrumbHome": "Home",
"breadcrumbTemplates": "Templates",
"title": "Video Templates for All Your Needs",
"subtitle": "Find customizable video templates. Create animated promos, logo reveals, slideshows, and more with FlatRender's online video maker."
},
"componentsTemplatesVideoVideoTemplatesPageContent": {
"openTemplateError": "Could not open template: {error}",
"emptyStateTitle": "No templates match your filters",
"emptyStateDescription": "Try a different size, category, or search term."
},
"componentsTemplatesVideoVideoTemplatesToolbar": {
"searchPlaceholder": "Search thousands of templates",
"sortByLabel": "Sort by:",
"sortAriaLabel": "Sort templates",
"sortTrending": "Trending",
"sortNewest": "Newest",
"sortPopular": "Most Popular"
},
"componentsTrimmerTrimmerExportSection": {
"heading": "Export",
"processing": "Processing…",
"trimAndCrop": "Trim & Crop",
"loadingEngine": "Loading FFmpeg engine…",
"progress": "Progress",
"download": "Download {format}"
},
"componentsTrimmerTrimmerStrip": {
"heading": "Trim",
"trimStart": "Trim start",
"trimEnd": "Trim end"
},
"componentsTrimmerTrimmerUploadZone": {
"dropPrompt": "Drag & drop a video, or click to browse",
"supportedFormats": "MP4, WebM, MOV and other video formats"
},
"componentsDashboardDashboardSidebar": {
"currentPlan": "Current plan",
"signOut": "Sign out"
},
"componentsDashboardDashboardSidebarNav": {
"myProjects": "My Projects",
"templates": "Templates",
"upgrade": "Upgrade",
"settings": "Settings",
"navLabel": "Dashboard"
},
"componentsDashboardDashboardTopBar": {
"searchPlaceholder": "Search projects..."
},
"componentsSectionsPricingCompareTable": {
"mostPopular": "Most Popular",
"compareHeading": "Compare Plans & Features",
"saveUpTo": "Save up to {percent}%"
},
"componentsSectionsPricingCreditsBanner": {
"refillCredits": "You can refill AI credits anytime with an active plan"
},
"componentsSectionsPricingFeatureList": {
"moreInformation": "More information"
},
"componentsSectionsPricingFreeBanner": {
"title": "Always Free to Try",
"description": "Explore CreatorStudio with a Free plan — create HD videos with a watermark, try basic features, and experiment before you subscribe.",
"ctaLabel": "Get Started"
},
"componentsSectionsTemplateCard": {
"useTemplateLabel": "Use Template",
"openingLabel": "Opening…",
"viewTemplateAriaLabel": "View {name} template"
},
"componentsSectionsTestimonialCard": {
"ratingLabel": "Rated 5 out of 5 stars"
},
"componentsTemplatesTemplateDetailBreadcrumb": {
"breadcrumbAriaLabel": "Breadcrumb",
"home": "Home",
"templates": "Templates"
},
"appImageMakerPage": {
"metaTitle": "AI Image Maker",
"metaDescription": "Design professional visuals instantly with AI generation, templates, brand kits, and batch export."
},
"appPage": {
"metaTitle": "Create Pro Videos & Images with AI",
"metaDescription": "FlatRender helps creators and brands make professional videos and images with AI templates, editors, and one-click export."
},
"componentsDashboardNewProjectMenu": {
"newProject": "New Project",
"creating": "Creating…",
"videoProject": "Video Project",
"imageProject": "Image Project",
"trimCropVideo": "Trim/Crop Video"
},
"componentsDashboardProjectCard": {
"openInStudio": "Open in Studio",
"download": "Download",
"rename": "Rename",
"duplicate": "Duplicate",
"delete": "Delete",
"statusRendering": "Rendering",
"statusReady": "Ready",
"statusDraft": "Draft",
"actionsFor": "Actions for {name}"
},
"componentsSectionsPricingCheckoutButton": {
"checkoutFailed": "Checkout failed.",
"noCheckoutUrl": "No checkout URL returned."
},
"componentsTemplatesTemplatesSidebar": {
"categoryHeading": "Category",
"styleHeading": "Style",
"colorHeading": "Color"
},
"componentsTemplatesVideoVideoTemplateCompactCard": {
"viewTemplateAria": "View {name} template",
"opening": "Opening…",
"useTemplate": "Use Template",
"sceneCount": "{count} scenes"
},
"componentsTemplatesVideoVideoTemplatesCarouselRow": {
"seeAll": "See all",
"scrollLeftAria": "Scroll {title} left",
"scrollRightAria": "Scroll {title} right"
},
"componentsTemplatesVideoVideoTemplatesCategorySidebar": {
"categoriesNavLabel": "Template categories",
"categoryAll": "All Templates",
"categoryAnimation": "Animation Videos",
"categoryIntros": "Intros and Logos",
"categoryEditing": "Video Editing",
"categoryInvitation": "Invitation Videos",
"categoryHoliday": "Holiday Videos",
"categorySlideshow": "Slideshow",
"categoryPresentations": "Presentations",
"categorySocial": "Social Media Videos",
"categoryAds": "Video Ad Templates",
"categorySales": "Sales Videos",
"categoryMusic": "Music Visualization",
"filters": "Filters",
"sizeLabel": "Size"
},
"componentsTemplatesVideoVideoTemplatesFilterControls": {
"premiumOnly": "Premium Only",
"premiumOnlyAriaLabel": "Premium only",
"sizeAriaLabel": "Template size",
"sizePlaceholder": "All Sizes"
},
"componentsTrimmerTrimmerVideoPreview": {
"previewAndCrop": "Preview & crop",
"aspectFree": "Free",
"aspect16x9": "16:9",
"aspect9x16": "9:16",
"aspect1x1": "1:1",
"aspect4x3": "4:3"
},
"componentsVideoMakerVideoMakerEditorPreview": {
"appBarTitle": "CreatorStudio — Video Editor",
"sceneCaption": "Scene 2 · Product reveal · 00:12",
"layersHeading": "Layers",
"layerIntroTitle": "Intro title",
"layerBrollClip": "B-roll clip",
"layerBackgroundMusic": "Background music",
"layerCaptions": "Captions"
},
"componentsVideoMakerVideoMakerTemplateCarousel": {
"title": "Video templates for every story",
"subtitle": "Start from a proven layout and customize scenes, text, and music in minutes.",
"templatePromo": "Product Promo",
"templateYoutube": "YouTube Intro",
"templateReel": "Reel Hook",
"templateCorporate": "Corporate Update",
"templateAd": "Ad Spotlight",
"templateTutorial": "Tutorial",
"templateEvent": "Event Recap",
"templateTestimonial": "Customer Story"
},
"componentsImageEditorAiRemoveBgModal": {
"openImageFirst": "Open an image first.",
"removalFailed": "Background removal failed.",
"backgroundRemoved": "Background removed!",
"serviceUnreachable": "Could not reach background removal service.",
"title": "AI Background Removal",
"description": "Remove the background from your base image. The result replaces the background layer with a transparent PNG.",
"processing": "Processing…",
"removeBackground": "Remove Background"
},
"componentsImageEditorImageCropControls": {
"aspectFree": "Free",
"cancel": "Cancel",
"applying": "Applying…",
"applyCrop": "Apply Crop"
},
"componentsImageEditorImageEditorRightPanel": {
"tabAdjust": "Adjust",
"tabFilters": "Filters",
"tabLayers": "Layers"
},
"componentsImageEditorImageEditorToolbar": {
"toolSelect": "Select",
"toolCrop": "Crop",
"toolText": "Text",
"toolShape": "Shape",
"toolDraw": "Draw",
"toolAi": "AI",
"shapeRectangle": "Rectangle",
"shapeCircle": "Circle",
"shapeLine": "Line",
"shapeArrow": "Arrow"
},
"componentsImageEditorImageEditorTopBar": {
"defaultProjectName": "Image Editor",
"open": "Open",
"export": "Export",
"format": "Format",
"quality": "Quality",
"download": "Download",
"canvasNotReady": "Canvas not ready.",
"exportStarted": "Export started"
},
"componentsImageEditorPanelsAdjustPanel": {
"emptyState": "Open an image to use adjustments.",
"brightness": "Brightness",
"contrast": "Contrast",
"saturation": "Saturation",
"hue": "Hue",
"blur": "Blur",
"sharpen": "Sharpen",
"vignette": "Vignette"
},
"componentsImageEditorPanelsFiltersPanel": {
"emptyState": "Open an image to apply filters."
},
"componentsImageEditorPanelsLayersPanel": {
"reorderLayer": "Reorder {name}",
"hideLayer": "Hide layer",
"showLayer": "Show layer",
"deleteLayer": "Delete {name}",
"emptyState": "No layers yet."
},
"componentsStudioAddSceneMenu": {
"addScene": "Add Scene",
"blankScene": "Blank Scene",
"fromTemplate": "From Template"
},
"componentsStudioDraggableSceneItem": {
"dragScene": "Drag scene {name}",
"sceneNameLabel": "Scene name"
},
"componentsStudioProjectSaveIndicator": {
"saving": "Saving…",
"saved": "Saved",
"localSave": "Local save",
"saveFailed": "Save failed",
"retry": "Retry"
},
"componentsStudioPropertiesPanel": {
"title": "Properties",
"emptyState": "Select a layer to edit properties",
"layerLabel": "{type} layer"
},
"componentsStudioRenderModal": {
"dialogTitle": "Export",
"dialogDescription": "Export your project as MP4 via the nexrender pipeline.",
"videoReady": "Your video is ready.",
"downloadMp4": "Download MP4",
"shareLink": "Share link",
"close": "Close",
"errorGeneric": "Something went wrong.",
"retry": "Retry",
"previewAlt": "Render preview",
"rendering": "Rendering…",
"progress": "Progress",
"resolution": "Resolution",
"format": "Format",
"fps": "FPS",
"startRendering": "Start Rendering",
"errorFetchStatus": "Could not fetch render status.",
"renderingProgress": "Rendering… {progress}%",
"errorRenderFailed": "Render failed.",
"errorNetworkPolling": "Network error while polling status.",
"errorStartRender": "Failed to start render.",
"queued": "Queued for rendering…",
"errorReachApi": "Could not reach render API."
},
"componentsStudioSceneBrowserCard": {
"selectCta": "Select"
},
"componentsStudioSceneBrowserModal": {
"title": "Select Scenes",
"closeAriaLabel": "Close",
"filterAll": "All",
"filterVideo": "Video",
"filterPhoto": "Photo",
"searchPlaceholder": "Search scenes...",
"emptyState": "No scenes match your filters.",
"selectedSuffix": "{count, plural, one {scene selected} other {scenes selected}}",
"deselectAll": "Deselect All",
"cancel": "Cancel",
"addToVideo": "Add to Video",
"addToVideoCount": "Add to Video ({count})"
},
"componentsStudioSceneItemActions": {
"duplicate": "Duplicate {sceneName}",
"delete": "Delete {sceneName}"
},
"componentsStudioSceneTransitionPicker": {
"transition": "Transition"
},
"componentsStudioStudioMobileGate": {
"titleVideo": "The Video Studio requires a desktop browser.",
"titleImage": "The Image Editor requires a desktop browser.",
"description": "Please open this project on a desktop or laptop.",
"dashboardCta": "Go to Dashboard"
},
"componentsStudioStudioToolbar": {
"defaultText": "Edit this text",
"addText": "Add text",
"addImage": "Add image",
"addVideoClip": "Add video clip",
"addShape": "Add shape",
"shapeRectangle": "Rectangle",
"shapeCircle": "Circle",
"shapeLine": "Line",
"shapeArrow": "Arrow"
},
"componentsStudioCanvasVideoLayerNode": {
"defaultFileName": "Video",
"placeholder": "Video clip"
},
"componentsStudioPropertiesCommonLayerControls": {
"transformTitle": "Transform",
"widthLabel": "Width",
"heightLabel": "Height",
"rotationLabel": "Rotation (°)",
"layerOrderTitle": "Layer order",
"toFront": "To front",
"toBack": "To back",
"deleteLayer": "Delete layer"
},
"componentsStudioPropertiesImageLayerProperties": {
"sectionTitle": "Image",
"opacity": "Opacity",
"flipHorizontal": "Flip H",
"flipVertical": "Flip V",
"replaceImage": "Replace image",
"borderRadius": "Border radius"
},
"componentsStudioPropertiesPropertyControls": {
"lockAspectRatio": "Lock aspect ratio",
"unlockAspectRatio": "Unlock aspect ratio"
},
"componentsStudioPropertiesShapeLayerProperties": {
"sectionTitle": "Shape",
"fillColor": "Fill color",
"strokeColor": "Stroke color",
"strokeWidth": "Stroke width",
"borderRadius": "Border radius",
"opacity": "Opacity"
},
"componentsStudioPropertiesTextLayerProperties": {
"sectionTitle": "Text",
"fontFamily": "Font family",
"fontSize": "Font size",
"bold": "Bold",
"italic": "Italic",
"underline": "Underline",
"textColor": "Text color",
"alignment": "Alignment",
"alignLeft": "Left",
"alignCenter": "Center",
"alignRight": "Right",
"letterSpacing": "Letter spacing",
"lineHeight": "Line height",
"opacity": "Opacity",
"animation": "Animation"
},
"componentsStudioSidebarAudioSidebarContent": {
"musicTab": "Music",
"voiceoverTab": "Voiceover"
},
"componentsStudioSidebarAudioSidebarMusicTab": {
"upload": "Upload",
"includeTemplateSfx": "Include template sound effect",
"searchPlaceholder": "Search music",
"musicLibrary": "Music library",
"myMusic": "My music",
"uploadOwnMusic": "Upload your own music"
},
"componentsStudioSidebarAudioSidebarVoiceoverPane": {
"comingSoon": "Coming soon",
"description": "Generate voiceovers from your script directly in the studio."
},
"componentsStudioSidebarColorsCustomTab": {
"mainColor": "Main Color",
"additionalColor": "Additional Color",
"applyToAllScenes": "Apply to all scenes"
},
"componentsStudioSidebarColorsPalettesTab": {
"paletteFallback": "Palette {number}",
"applyPaletteAriaLabel": "Apply {name} palette"
},
"componentsStudioSidebarColorsSidebarContent": {
"palettesTab": "Palettes",
"customTab": "Custom"
},
"componentsStudioSidebarColorsTemplatePreviewCard": {
"mainColor": "Main Color",
"additional": "Additional",
"paletteFallback": "Palette {number}"
},
"componentsStudioSidebarFontSidebarContent": {
"title": "Font",
"fontFamily": "Font family",
"applyToAll": "Apply to all text layers"
},
"componentsStudioSidebarSceneEditSidebarContent": {
"panelTitle": "Edit Scene",
"titleLabel": "Title",
"subtitleLabel": "Subtitle",
"textLabel": "Text {index}",
"textPlaceholder": "Type here…",
"imageLabel": "Image {index}",
"emptyStateTitle": "This scene has no content yet.",
"emptyStateHint": "Add a text layer to start editing.",
"addTextLayer": "Add Text Layer",
"defaultText": "Your text here",
"replaceImage": "Replace image",
"uploadImage": "Upload image"
},
"componentsStudioSidebarTransitionsSidebarContent": {
"heading": "Transitions",
"randomTransition": "Random Transition",
"noTransition": "No Transition",
"exportNote": "Applied transitions will be visible on all scenes after export."
},
"componentsStudioSidebarTtsSidebarContent": {
"title": "Text to Speech",
"comingSoon": "Coming soon",
"description": "Generate voiceovers from your script directly in the studio."
},
"componentsStudioSidebarWatermarkSidebarContent": {
"title": "My Watermark",
"applyToAllScenes": "Apply to all scenes",
"uploadLogo": "Upload your watermark logo",
"uploadHint": "PNG or SVG, max 2MB",
"position": "Position",
"positionTopLeft": "Top left",
"positionTopCenter": "Top center",
"positionTopRight": "Top right",
"positionMiddleLeft": "Middle left",
"positionCenter": "Center",
"positionMiddleRight": "Middle right",
"positionBottomLeft": "Bottom left",
"positionBottomCenter": "Bottom center",
"positionBottomRight": "Bottom right",
"opacity": "Opacity",
"opacityAriaLabel": "Watermark opacity"
},
"componentsStudioTimelineAudioTrack": {
"emptyState": "No audio — click to add"
},
"componentsStudioTimelineSceneBlock": {
"resizeDuration": "Resize {name} duration"
},
"componentsStudioTimelineSceneThumbnailBlock": {
"duplicateScene": "Duplicate {name}",
"deleteScene": "Delete {name}",
"resizeSceneDuration": "Resize {name} duration",
"sceneNameLabel": "Scene name",
"doubleClickToRename": "Double-click to rename"
},
"componentsStudioTimelineSceneThumbnailStrip": {
"browseScenes": "Browse scenes",
"addScene": "Add scene"
},
"componentsStudioTimelineTimeRuler": {
"rulerAriaLabel": "Timeline ruler — click to seek"
},
"componentsStudioTimelineTimelineActionRow": {
"addTextToSpeech": "Add text to speech",
"addAudio": "Add audio"
},
"componentsStudioTimelineTimelineControlBar": {
"copyLayer": "Copy layer",
"deleteLayer": "Delete layer",
"stop": "Stop",
"preview": "Preview",
"previewFromStart": "Preview from start",
"seekToStart": "Seek to start",
"zoomOut": "Zoom out",
"zoomIn": "Zoom in",
"timelineZoom": "Timeline zoom"
},
"componentsStudioTimelineTimelineQuickActions": {
"addTextToSpeech": "Add text to speech",
"addAudio": "Add audio"
},
"componentsStudioVideoCanvasArea": {
"loading": "Loading canvas…",
"editingNotice": "You're in editing mode — visuals may look different. Press <preview>Preview</preview> to see the final result."
},
"componentsStudioVideoStudioSidebarDock": {
"scenes": "Scenes",
"audio": "Audio",
"textToSpeech": "Text to Speech",
"colors": "Colors",
"transitions": "Transitions",
"font": "Font",
"myWatermark": "My Watermark",
"toolsNavLabel": "Studio tools",
"guideMe": "Guide me",
"guideComingSoon": "👋 Guide coming soon!",
"keyboardShortcuts": "Keyboard shortcuts",
"keyboardShortcutsComingSoon": "Keyboard shortcuts coming soon!"
},
"componentsStudioVideoStudioTopBar": {
"snapshotSaved": "Snapshot saved!",
"canvasNotReady": "Canvas not ready. Try again.",
"homeLink": "FlatRender home",
"breadcrumb": "Breadcrumb",
"myProjects": "My Projects",
"projectName": "Project name",
"undo": "Undo",
"redo": "Redo",
"stop": "Stop",
"preview": "Preview",
"takeSnapshot": "Take snapshot",
"export": "Export"
},
"componentsStudioVideoStudioTopBarSaveBadge": {
"savingTitle": "Saving…",
"savingLabel": "Saving",
"errorTitle": "Save failed",
"errorLabel": "Save failed",
"local": "Local",
"saved": "Saved ✓"
},
"componentsStudioVideoStudioTopBarTextControls": {
"groupLabel": "Text layer properties",
"fontFamily": "Font family",
"fontSize": "Font size",
"bold": "Bold",
"italic": "Italic",
"textColor": "Text color"
},
"componentsStudioVideoVideoNewPresetCard": {
"useTemplate": "Use Template"
},
"componentsStudioVideoVideoProjectNewContent": {
"breadcrumbCreate": "Create new video",
"heading": "Select one of the options to start creating",
"selectScenesTitle": "Select Scenes",
"selectScenesDescription": "Browse scenes and build your project from scratch",
"createWithAiTitle": "Create with AI",
"createWithAiDescription": "Transform your ideas or script into AI-generated videos effortlessly",
"aiProjectName": "AI Video Project",
"or": "OR",
"startWithPresets": "Start with Presets",
"searchPresetsPlaceholder": "Search presets...",
"newVideoName": "New Video"
},
"adminAi": {
"pageTitle": "AI SEO Content",
"pageDesc": "Configure OpenAI and generate SEO-optimized articles from a description.",
"settingsTitle": "OpenAI configuration",
"settingsDesc": "Your API key is stored securely and never shown in full. Point Base URL at a reachable OpenAI-compatible endpoint if needed.",
"apiKeyLabel": "API key",
"apiKeyPlaceholder": "sk-… (leave blank to keep current)",
"baseUrlLabel": "Base URL",
"modelLabel": "Model",
"enabledLabel": "Enable AI generation",
"saveSettings": "Save settings",
"saving": "Saving…",
"settingsSaved": "Settings saved",
"settingsError": "Could not save settings",
"keyConfigured": "API key configured",
"noKey": "No API key set",
"generateTitle": "Generate SEO article",
"generateDesc": "Describe the topic and metadata — the AI writes an SEO-ready post.",
"descriptionLabel": "Description / brief",
"descriptionPlaceholder": "What is this page/product about? Key points, tone, goals…",
"titleLabel": "Working title (optional)",
"typeLabel": "Content type (optional)",
"typePlaceholder": "e.g. video template",
"tagsLabel": "Tags (comma separated, optional)",
"keywordLabel": "Primary keyword (optional)",
"audienceLabel": "Audience (optional)",
"localeLabel": "Language",
"localeFa": "Persian",
"localeEn": "English",
"generate": "Generate",
"generating": "Generating…",
"generateError": "Generation failed",
"resultTitle": "Generated article",
"fTitle": "Title",
"fSlug": "Slug",
"fMetaTitle": "Meta title",
"fMetaDesc": "Meta description",
"fKeywords": "Keywords",
"fShortDesc": "Short description",
"fContent": "Content (HTML)",
"preview": "Preview",
"publishNow": "Publish immediately",
"saveAsBlog": "Save as blog post",
"savedAsBlog": "Saved as blog post",
"saveError": "Could not save post",
"mustConfigure": "Configure and enable OpenAI above before generating."
}
}
}
+900
View File
@@ -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": "با ادامه دادن، با <terms>قوانین</terms> و <privacy>سیاست حفظ حریم خصوصی</privacy> ما موافقت می‌کنید."
},
"componentsAuthSupabaseSetupNotice": {
"title": "Supabase پیکربندی نشده است",
"instructions": "فایل <envExample></envExample> را به <envLocal></envLocal> کپی کنید و مقادیر <supabaseUrl></supabaseUrl> و <supabaseAnonKey></supabaseAnonKey> را تنظیم کنید، سپس سرور توسعه را دوباره راه‌اندازی کنید.",
"continueDev": "ادامه بدون ورود (فقط حالت توسعه)",
"backToHome": "بازگشت به خانه"
},
"componentsDashboardDashboardEmptyState": {
"title": "هنوز پروژه‌ای ندارید",
"description": "یک پروژه ویدیو، تصویر یا برش بسازید تا اینجا نمایش داده شود. هر چه ذخیره کنید در این فضای کاری ظاهر می‌شود.",
"createFirstProject": "اولین پروژه خود را بسازید"
},
"componentsDashboardDashboardPlanBadge": {
"upgradePlan": "ارتقای اشتراک"
},
"componentsDashboardDashboardProjectsSection": {
"recentProjects": "پروژه‌های اخیر",
"noResultsTitle": "هیچ پروژه‌ای با جستجوی شما مطابقت ندارد",
"noResultsDescription": "کلمه کلیدی دیگری را امتحان کنید یا نوار جستجو را پاک کنید."
},
"componentsDashboardSettingsSettingsBilling": {
"title": "صورتحساب و اشتراک",
"subtitle": "اشتراک و روش پرداخت خود را مدیریت کنید.",
"currentPlan": "اشتراک فعلی",
"planFree": "رایگان",
"planPro": "حرفه‌ای",
"planBusiness": "تجاری",
"statusCancelsAtPeriodEnd": "در پایان دوره لغو می‌شود",
"statusActive": "فعال",
"statusFreeTier": "نسخه رایگان",
"upgrade": "ارتقا",
"changePlan": "تغییر اشتراک",
"cancelPlan": "لغو اشتراک",
"cancelling": "در حال لغو…",
"cancelConfirm": "اشتراک خود را لغو می‌کنید؟ تا پایان دوره فعلی دسترسی شما حفظ می‌شود.",
"cancelFailed": "لغو اشتراک ناموفق بود. لطفاً دوباره تلاش کنید.",
"networkError": "خطای شبکه. لطفاً دوباره تلاش کنید.",
"cancelledNotice": "اشتراک شما لغو شد. تا پایان دوره صورتحساب، دسترسی شما حفظ می‌شود.",
"upgradeHint": "برای دسترسی به پروژه‌های نامحدود، خروجی ۴K و قالب‌های ویژه، اشتراک خود را ارتقا دهید.",
"featureFree5Projects": "۵ پروژه",
"featureFree720pExport": "خروجی ۷۲۰p",
"featureFreeCommunityTemplates": "قالب‌های عمومی",
"featureProUnlimitedProjects": "پروژه‌های نامحدود",
"featurePro4kExport": "خروجی ۴K",
"featureProAllTemplates": "همه قالب‌ها",
"featureProPriorityRenderQueue": "صف رندر اولویت‌دار",
"featureProCustomFonts": "فونت‌های سفارشی",
"featureBusinessEverythingInPro": "همه امکانات نسخه حرفه‌ای",
"featureBusinessTeamSeats": "صندلی‌های تیمی",
"featureBusinessWhiteLabelExport": "خروجی بدون برند",
"featureBusinessApiAccess": "دسترسی به API",
"featureBusinessDedicatedSupport": "پشتیبانی اختصاصی"
},
"componentsDashboardSettingsSettingsNotifications": {
"title": "اعلان‌ها",
"subtitle": "انتخاب کنید چه ایمیل‌هایی از فلت‌رندر دریافت کنید.",
"savePreferences": "ذخیره تنظیمات",
"saved": "ذخیره شد!",
"renderCompleteLabel": "اتمام رندر",
"renderCompleteDescription": "هنگام پایان خروجی گرفتن از ویدیو به شما اطلاع داده می‌شود.",
"projectSharedLabel": "اشتراک‌گذاری پروژه با شما",
"projectSharedDescription": "هنگامی که یکی از اعضای تیم پروژه‌ای را با شما به اشتراک می‌گذارد.",
"weeklyDigestLabel": "خلاصه هفتگی",
"weeklyDigestDescription": "خلاصه‌ای از قالب‌های جدید و به‌روزرسانی‌های پلتفرم.",
"productNewsLabel": "اخبار محصول",
"productNewsDescription": "امکانات جدید، نکته‌ها و اطلاعیه‌ها."
},
"componentsDashboardSettingsSettingsProfile": {
"title": "پروفایل",
"subtitle": "نام عمومی و ایمیل حساب شما.",
"displayNameLabel": "نام نمایشی",
"displayNamePlaceholder": "نام شما",
"emailLabel": "ایمیل",
"emailHint": "ایمیل را از اینجا نمی‌توان تغییر داد. با پشتیبانی تماس بگیرید.",
"saving": "در حال ذخیره…",
"saveChanges": "ذخیره تغییرات",
"updateFailed": "به‌روزرسانی پروفایل ممکن نشد.",
"updateSuccess": "پروفایل با موفقیت به‌روزرسانی شد.",
"networkError": "خطای شبکه. لطفاً دوباره تلاش کنید."
},
"componentsDashboardSettingsSettingsSecurity": {
"title": "امنیت",
"subtitle": "رمز عبور حساب خود را تغییر دهید.",
"currentPasswordLabel": "رمز عبور فعلی",
"newPasswordLabel": "رمز عبور جدید",
"confirmPasswordLabel": "تکرار رمز عبور جدید",
"showPassword": "نمایش رمز عبور",
"hidePassword": "پنهان کردن رمز عبور",
"saving": "در حال ذخیره…",
"changePassword": "تغییر رمز عبور",
"errorMinLength": "رمز عبور جدید باید حداقل ۸ کاراکتر باشد.",
"errorMismatch": "رمزهای عبور مطابقت ندارند.",
"errorChangeFailed": "تغییر رمز عبور ممکن نشد.",
"changeSuccess": "رمز عبور با موفقیت تغییر کرد.",
"networkError": "خطای شبکه. لطفاً دوباره تلاش کنید."
},
"componentsImageMakerImageMakerBeforeAfter": {
"beforeAlt": "قبل از ویرایش",
"afterAlt": "بعد از ویرایش با هوش مصنوعی",
"beforeLabel": "قبل",
"afterLabel": "بعد",
"caption": "رنگ، چیدمان و استایل برند با هوش مصنوعی، تنها با یک کلیک اعمال می‌شود"
},
"componentsImageMakerImageMakerGallery": {
"title": "نمونه‌هایی از ساخته‌های سازندگان",
"subtitle": "چیدمان‌ها و سبک‌های واقعی که می‌توانید بازآفرینی کنید—یا از آن‌ها برای پروژه‌ بعدی‌تان الهام بگیرید."
},
"componentsLayoutNavbarMenuDropdown": {
"learn": "آموزش"
},
"componentsLayoutNavbarMobileMenu": {
"videoMaker": "ویدیوساز",
"imageMaker": "تصویرساز",
"pricing": "قیمت‌گذاری",
"learn": "آموزش"
},
"componentsSectionsHeroPreviewCards": {
"heading": "ساخته‌شده توسط طراحان موشن‌گرافیک در سطح جهانی",
"previewAriaLabel": "پیش‌نمایش {label}",
"template3dTitle": "کارخانه انیمیشن‌های سه‌بعدی",
"templateWhiteboardTitle": "جعبه‌ابزار انیمیشن وایت‌بردی",
"templateExplainerTitle": "جعبه‌ابزار ویدیوی توضیحی سه‌بعدی",
"templateTrendyTitle": "جعبه‌ابزار ویدیوی توضیحی ترِند"
},
"componentsSectionsPricingAnimatedPrice": {
"perMonth": "/ ماهانه"
},
"componentsSectionsPricingBillingToggle": {
"monthly": "ماهانه",
"yearly": "سالانه",
"savePercent": "{percent}٪ صرفه‌جویی",
"switchToYearly": "برای صرفه‌جویی بیشتر به پرداخت سالانه تغییر دهید"
},
"componentsSectionsPricingCard": {
"mostPopular": "محبوب‌ترین"
},
"componentsTemplatesTemplateDetailExamples": {
"heading": "ویدیوهای ساخته‌شده با این قالب"
},
"componentsTemplatesTemplateDetailInfo": {
"sceneCount": "{count} صحنه",
"durationFlexible": "انعطاف‌پذیر",
"durationFixed": "ثابت",
"fallbackDescription": "با این قالب حرفه‌ای ویدیوهای چشم‌نواز بسازید. صحنه‌ها را انتخاب کنید، متن را سفارشی کنید و در چند دقیقه خروجی بگیرید.",
"availableStyles": "سبک‌های موجود ({count})",
"styleClassic": "کلاسیک",
"styleModern": "مدرن",
"styleBold": "پررنگ",
"styleMinimal": "مینیمال",
"createNow": "همین حالا بساز",
"removeFromFavorites": "حذف از علاقه‌مندی‌ها",
"addToFavorites": "افزودن به علاقه‌مندی‌ها",
"createError": "ساخت پروژه ممکن نشد: {error}"
},
"componentsTemplatesTemplateDetailPreview": {
"posterAlt": "پیش‌نمایش {name}",
"playPreview": "پخش پیش‌نمایش قالب"
},
"componentsTemplatesTemplateDetailRating": {
"starsAriaLabel": "{score} از ۵ ستاره",
"ratingsCount": "({count} امتیاز)"
},
"componentsTemplatesTemplatesActiveFilters": {
"removeFilter": "حذف فیلتر: {label}",
"searchLabel": "جستجو: «{query}»"
},
"componentsTemplatesVideoVideoTemplatesHero": {
"breadcrumbHome": "خانه",
"breadcrumbTemplates": "قالب‌ها",
"title": "قالب‌های ویدیویی برای هر نیازی",
"subtitle": "قالب‌های ویدیویی قابل‌شخصی‌سازی را پیدا کنید. با ویدیوساز آنلاین فلت‌رندر، تیزرهای انیمیشنی، نمایش لوگو، اسلایدشو و موارد دیگر بسازید."
},
"componentsTemplatesVideoVideoTemplatesPageContent": {
"openTemplateError": "باز کردن قالب ممکن نشد: {error}",
"emptyStateTitle": "هیچ قالبی با فیلترهای شما مطابقت ندارد",
"emptyStateDescription": "اندازه، دسته‌بندی یا عبارت جست‌وجوی دیگری را امتحان کنید."
},
"componentsTemplatesVideoVideoTemplatesToolbar": {
"searchPlaceholder": "جست‌وجو در هزاران قالب",
"sortByLabel": "مرتب‌سازی بر اساس:",
"sortAriaLabel": "مرتب‌سازی قالب‌ها",
"sortTrending": "پرطرفدار",
"sortNewest": "جدیدترین",
"sortPopular": "محبوب‌ترین"
},
"componentsTrimmerTrimmerExportSection": {
"heading": "خروجی",
"processing": "در حال پردازش…",
"trimAndCrop": "برش و کراپ",
"loadingEngine": "در حال بارگذاری موتور FFmpeg…",
"progress": "پیشرفت",
"download": "دانلود {format}"
},
"componentsTrimmerTrimmerStrip": {
"heading": "برش",
"trimStart": "شروع برش",
"trimEnd": "پایان برش"
},
"componentsTrimmerTrimmerUploadZone": {
"dropPrompt": "ویدیو را بکشید و رها کنید، یا برای انتخاب کلیک کنید",
"supportedFormats": "MP4، WebM، MOV و دیگر فرمت‌های ویدیویی"
},
"componentsDashboardDashboardSidebar": {
"currentPlan": "پلن فعلی",
"signOut": "خروج از حساب"
},
"componentsDashboardDashboardSidebarNav": {
"myProjects": "پروژه‌های من",
"templates": "قالب‌ها",
"upgrade": "ارتقا",
"settings": "تنظیمات",
"navLabel": "داشبورد"
},
"componentsDashboardDashboardTopBar": {
"searchPlaceholder": "جستجوی پروژه‌ها..."
},
"componentsSectionsPricingCompareTable": {
"mostPopular": "محبوب‌ترین",
"compareHeading": "مقایسه پلن‌ها و امکانات",
"saveUpTo": "تا {percent}٪ صرفه‌جویی کنید"
},
"componentsSectionsPricingCreditsBanner": {
"refillCredits": "با داشتن یک پلن فعال می‌توانید هر زمان که خواستید اعتبار هوش مصنوعی خود را شارژ کنید"
},
"componentsSectionsPricingFeatureList": {
"moreInformation": "اطلاعات بیشتر"
},
"componentsSectionsPricingFreeBanner": {
"title": "همیشه رایگان برای امتحان",
"description": "با پلن رایگان، CreatorStudio را تجربه کنید — ویدیوهای HD همراه با واترمارک بسازید، امکانات پایه را امتحان کنید و پیش از خرید اشتراک آزمایش کنید.",
"ctaLabel": "شروع کنید"
},
"componentsSectionsTemplateCard": {
"useTemplateLabel": "استفاده از قالب",
"openingLabel": "در حال باز کردن…",
"viewTemplateAriaLabel": "مشاهده قالب {name}"
},
"componentsSectionsTestimonialCard": {
"ratingLabel": "امتیاز ۵ از ۵ ستاره"
},
"componentsTemplatesTemplateDetailBreadcrumb": {
"breadcrumbAriaLabel": "مسیر راهنما",
"home": "خانه",
"templates": "قالب‌ها"
},
"appImageMakerPage": {
"metaTitle": "ساخت تصویر با هوش مصنوعی",
"metaDescription": "تصاویر حرفه‌ای را در لحظه با تولید هوشمند، قالب‌ها، کیت‌های برند و خروجی گروهی طراحی کنید."
},
"appPage": {
"metaTitle": "ساخت ویدیو و تصویر حرفه‌ای با هوش مصنوعی",
"metaDescription": "فلت‌رندر به سازندگان محتوا و برندها کمک می‌کند تا با قالب‌ها، ویرایشگرها و خروجی تک‌کلیکی هوش مصنوعی، ویدیو و تصویر حرفه‌ای بسازند."
},
"componentsDashboardNewProjectMenu": {
"newProject": "پروژه جدید",
"creating": "در حال ساخت…",
"videoProject": "پروژه ویدیویی",
"imageProject": "پروژه تصویری",
"trimCropVideo": "برش/کراپ ویدیو"
},
"componentsDashboardProjectCard": {
"openInStudio": "باز کردن در استودیو",
"download": "دانلود",
"rename": "تغییر نام",
"duplicate": "ایجاد نسخه مشابه",
"delete": "حذف",
"statusRendering": "در حال رندر",
"statusReady": "آماده",
"statusDraft": "پیش‌نویس",
"actionsFor": "عملیات برای {name}"
},
"componentsSectionsPricingCheckoutButton": {
"checkoutFailed": "پرداخت ناموفق بود.",
"noCheckoutUrl": "آدرس پرداخت دریافت نشد."
},
"componentsTemplatesTemplatesSidebar": {
"categoryHeading": "دسته‌بندی",
"styleHeading": "سبک",
"colorHeading": "رنگ"
},
"componentsTemplatesVideoVideoTemplateCompactCard": {
"viewTemplateAria": "مشاهده قالب {name}",
"opening": "در حال باز شدن…",
"useTemplate": "استفاده از قالب",
"sceneCount": "{count} صحنه"
},
"componentsTemplatesVideoVideoTemplatesCarouselRow": {
"seeAll": "مشاهده همه",
"scrollLeftAria": "اسکرول {title} به چپ",
"scrollRightAria": "اسکرول {title} به راست"
},
"componentsTemplatesVideoVideoTemplatesCategorySidebar": {
"categoriesNavLabel": "دسته‌بندی قالب‌ها",
"categoryAll": "همه قالب‌ها",
"categoryAnimation": "ویدیوهای انیمیشن",
"categoryIntros": "اینترو و لوگو",
"categoryEditing": "تدوین ویدیو",
"categoryInvitation": "ویدیوهای دعوت",
"categoryHoliday": "ویدیوهای مناسبتی",
"categorySlideshow": "اسلایدشو",
"categoryPresentations": "ارائه‌ها",
"categorySocial": "ویدیوهای شبکه‌های اجتماعی",
"categoryAds": "قالب‌های تبلیغاتی ویدیویی",
"categorySales": "ویدیوهای فروش",
"categoryMusic": "ویژوال موزیک",
"filters": "فیلترها",
"sizeLabel": "اندازه"
},
"componentsTemplatesVideoVideoTemplatesFilterControls": {
"premiumOnly": "فقط ویژه",
"premiumOnlyAriaLabel": "فقط ویژه",
"sizeAriaLabel": "اندازه قالب",
"sizePlaceholder": "همه اندازه‌ها"
},
"componentsTrimmerTrimmerVideoPreview": {
"previewAndCrop": "پیش‌نمایش و برش",
"aspectFree": "آزاد",
"aspect16x9": "۱۶:۹",
"aspect9x16": "۹:۱۶",
"aspect1x1": "۱:۱",
"aspect4x3": "۴:۳"
},
"componentsVideoMakerVideoMakerEditorPreview": {
"appBarTitle": "کریتور استودیو — ویرایشگر ویدیو",
"sceneCaption": "صحنه ۲ · معرفی محصول · ۰۰:۱۲",
"layersHeading": "لایه‌ها",
"layerIntroTitle": "عنوان آغازین",
"layerBrollClip": "کلیپ مکمل",
"layerBackgroundMusic": "موسیقی پس‌زمینه",
"layerCaptions": "زیرنویس‌ها"
},
"componentsVideoMakerVideoMakerTemplateCarousel": {
"title": "قالب‌های ویدیویی برای هر داستان",
"subtitle": "از یک طرح آماده شروع کنید و در چند دقیقه صحنه‌ها، متن و موسیقی را شخصی‌سازی کنید.",
"templatePromo": "تبلیغ محصول",
"templateYoutube": "اینترو یوتیوب",
"templateReel": "قلاب ریلز",
"templateCorporate": "خبر سازمانی",
"templateAd": "نمایش تبلیغاتی",
"templateTutorial": "آموزشی",
"templateEvent": "جمع‌بندی رویداد",
"templateTestimonial": "روایت مشتری"
},
"componentsImageEditorAiRemoveBgModal": {
"openImageFirst": "ابتدا یک تصویر باز کنید.",
"removalFailed": "حذف پس‌زمینه ناموفق بود.",
"backgroundRemoved": "پس‌زمینه حذف شد!",
"serviceUnreachable": "دسترسی به سرویس حذف پس‌زمینه ممکن نشد.",
"title": "حذف پس‌زمینه با هوش مصنوعی",
"description": "پس‌زمینه را از تصویر پایه حذف کنید. نتیجه، لایه پس‌زمینه را با یک PNG شفاف جایگزین می‌کند.",
"processing": "در حال پردازش…",
"removeBackground": "حذف پس‌زمینه"
},
"componentsImageEditorImageCropControls": {
"aspectFree": "آزاد",
"cancel": "انصراف",
"applying": "در حال اعمال…",
"applyCrop": "اعمال برش"
},
"componentsImageEditorImageEditorRightPanel": {
"tabAdjust": "تنظیمات",
"tabFilters": "فیلترها",
"tabLayers": "لایه‌ها"
},
"componentsImageEditorImageEditorToolbar": {
"toolSelect": "انتخاب",
"toolCrop": "برش",
"toolText": "متن",
"toolShape": "شکل",
"toolDraw": "ترسیم",
"toolAi": "هوش مصنوعی",
"shapeRectangle": "مستطیل",
"shapeCircle": "دایره",
"shapeLine": "خط",
"shapeArrow": "پیکان"
},
"componentsImageEditorImageEditorTopBar": {
"defaultProjectName": "ویرایشگر تصویر",
"open": "باز کردن",
"export": "خروجی گرفتن",
"format": "فرمت",
"quality": "کیفیت",
"download": "دانلود",
"canvasNotReady": "بوم آماده نیست.",
"exportStarted": "خروجی‌گیری آغاز شد"
},
"componentsImageEditorPanelsAdjustPanel": {
"emptyState": "برای استفاده از تنظیمات، یک تصویر باز کنید.",
"brightness": "روشنایی",
"contrast": "کنتراست",
"saturation": "اشباع رنگ",
"hue": "ته‌رنگ",
"blur": "محو شدگی",
"sharpen": "وضوح",
"vignette": "وینیت"
},
"componentsImageEditorPanelsFiltersPanel": {
"emptyState": "برای اعمال فیلترها یک تصویر باز کنید."
},
"componentsImageEditorPanelsLayersPanel": {
"reorderLayer": "تغییر ترتیب {name}",
"hideLayer": "پنهان کردن لایه",
"showLayer": "نمایش لایه",
"deleteLayer": "حذف {name}",
"emptyState": "هنوز لایه‌ای وجود ندارد."
},
"componentsStudioAddSceneMenu": {
"addScene": "افزودن صحنه",
"blankScene": "صحنه خالی",
"fromTemplate": "از روی قالب"
},
"componentsStudioDraggableSceneItem": {
"dragScene": "جابجایی صحنه {name}",
"sceneNameLabel": "نام صحنه"
},
"componentsStudioProjectSaveIndicator": {
"saving": "در حال ذخیره…",
"saved": "ذخیره شد",
"localSave": "ذخیره محلی",
"saveFailed": "ذخیره ناموفق بود",
"retry": "تلاش مجدد"
},
"componentsStudioPropertiesPanel": {
"title": "ویژگی‌ها",
"emptyState": "برای ویرایش ویژگی‌ها یک لایه را انتخاب کنید",
"layerLabel": "لایه {type}"
},
"componentsStudioRenderModal": {
"dialogTitle": "خروجی گرفتن",
"dialogDescription": "پروژه خود را از طریق خط پردازش nexrender به صورت MP4 خروجی بگیرید.",
"videoReady": "ویدیوی شما آماده است.",
"downloadMp4": "دانلود MP4",
"shareLink": "اشتراک‌گذاری لینک",
"close": "بستن",
"errorGeneric": "مشکلی پیش آمد.",
"retry": "تلاش دوباره",
"previewAlt": "پیش‌نمایش رندر",
"rendering": "در حال رندر…",
"progress": "پیشرفت",
"resolution": "وضوح تصویر",
"format": "فرمت",
"fps": "فریم بر ثانیه",
"startRendering": "شروع رندر",
"errorFetchStatus": "دریافت وضعیت رندر امکان‌پذیر نبود.",
"renderingProgress": "در حال رندر… {progress}٪",
"errorRenderFailed": "رندر ناموفق بود.",
"errorNetworkPolling": "خطای شبکه هنگام بررسی وضعیت.",
"errorStartRender": "شروع رندر ناموفق بود.",
"queued": "در صف رندر قرار گرفت…",
"errorReachApi": "دسترسی به سرویس رندر امکان‌پذیر نبود."
},
"componentsStudioSceneBrowserCard": {
"selectCta": "انتخاب"
},
"componentsStudioSceneBrowserModal": {
"title": "انتخاب صحنه‌ها",
"closeAriaLabel": "بستن",
"filterAll": "همه",
"filterVideo": "ویدیو",
"filterPhoto": "عکس",
"searchPlaceholder": "جستجوی صحنه‌ها...",
"emptyState": "هیچ صحنه‌ای با فیلترهای شما مطابقت ندارد.",
"selectedSuffix": "{count, plural, one {صحنه انتخاب شد} other {صحنه انتخاب شد}}",
"deselectAll": "لغو انتخاب همه",
"cancel": "انصراف",
"addToVideo": "افزودن به ویدیو",
"addToVideoCount": "افزودن به ویدیو ({count})"
},
"componentsStudioSceneItemActions": {
"duplicate": "تکثیر {sceneName}",
"delete": "حذف {sceneName}"
},
"componentsStudioSceneTransitionPicker": {
"transition": "گذار"
},
"componentsStudioStudioMobileGate": {
"titleVideo": "استودیوی ویدیو به مرورگر دسکتاپ نیاز دارد.",
"titleImage": "ویرایشگر تصویر به مرورگر دسکتاپ نیاز دارد.",
"description": "لطفاً این پروژه را روی رایانه رومیزی یا لپ‌تاپ باز کنید.",
"dashboardCta": "رفتن به داشبورد"
},
"componentsStudioStudioToolbar": {
"defaultText": "این متن را ویرایش کنید",
"addText": "افزودن متن",
"addImage": "افزودن تصویر",
"addVideoClip": "افزودن کلیپ ویدیویی",
"addShape": "افزودن شکل",
"shapeRectangle": "مستطیل",
"shapeCircle": "دایره",
"shapeLine": "خط",
"shapeArrow": "پیکان"
},
"componentsStudioCanvasVideoLayerNode": {
"defaultFileName": "ویدیو",
"placeholder": "کلیپ ویدیویی"
},
"componentsStudioPropertiesCommonLayerControls": {
"transformTitle": "تبدیل",
"widthLabel": "عرض",
"heightLabel": "ارتفاع",
"rotationLabel": "چرخش (°)",
"layerOrderTitle": "ترتیب لایه‌ها",
"toFront": "انتقال به جلو",
"toBack": "انتقال به عقب",
"deleteLayer": "حذف لایه"
},
"componentsStudioPropertiesImageLayerProperties": {
"sectionTitle": "تصویر",
"opacity": "شفافیت",
"flipHorizontal": "وارونه افقی",
"flipVertical": "وارونه عمودی",
"replaceImage": "جایگزینی تصویر",
"borderRadius": "گردی گوشه‌ها"
},
"componentsStudioPropertiesPropertyControls": {
"lockAspectRatio": "قفل نسبت ابعاد",
"unlockAspectRatio": "باز کردن قفل نسبت ابعاد"
},
"componentsStudioPropertiesShapeLayerProperties": {
"sectionTitle": "شکل",
"fillColor": "رنگ پرکننده",
"strokeColor": "رنگ خط دور",
"strokeWidth": "ضخامت خط دور",
"borderRadius": "گردی گوشه‌ها",
"opacity": "شفافیت"
},
"componentsStudioPropertiesTextLayerProperties": {
"sectionTitle": "متن",
"fontFamily": "خانواده فونت",
"fontSize": "اندازه فونت",
"bold": "ضخیم",
"italic": "مورب",
"underline": "زیرخط",
"textColor": "رنگ متن",
"alignment": "تراز",
"alignLeft": "چپ‌چین",
"alignCenter": "وسط‌چین",
"alignRight": "راست‌چین",
"letterSpacing": "فاصله حروف",
"lineHeight": "ارتفاع خط",
"opacity": "شفافیت",
"animation": "انیمیشن"
},
"componentsStudioSidebarAudioSidebarContent": {
"musicTab": "موسیقی",
"voiceoverTab": "صداگذاری"
},
"componentsStudioSidebarAudioSidebarMusicTab": {
"upload": "بارگذاری",
"includeTemplateSfx": "افزودن جلوه صوتی قالب",
"searchPlaceholder": "جستجوی موسیقی",
"musicLibrary": "کتابخانه موسیقی",
"myMusic": "موسیقی‌های من",
"uploadOwnMusic": "موسیقی خود را بارگذاری کنید"
},
"componentsStudioSidebarAudioSidebarVoiceoverPane": {
"comingSoon": "به‌زودی",
"description": "صداگذاری را مستقیماً از روی متن خود در استودیو بسازید."
},
"componentsStudioSidebarColorsCustomTab": {
"mainColor": "رنگ اصلی",
"additionalColor": "رنگ مکمل",
"applyToAllScenes": "اعمال به همه صحنه‌ها"
},
"componentsStudioSidebarColorsPalettesTab": {
"paletteFallback": "پالت {number}",
"applyPaletteAriaLabel": "اعمال پالت {name}"
},
"componentsStudioSidebarColorsSidebarContent": {
"palettesTab": "پالت‌ها",
"customTab": "سفارشی"
},
"componentsStudioSidebarColorsTemplatePreviewCard": {
"mainColor": "رنگ اصلی",
"additional": "رنگ مکمل",
"paletteFallback": "پالت {number}"
},
"componentsStudioSidebarFontSidebarContent": {
"title": "فونت",
"fontFamily": "خانواده فونت",
"applyToAll": "اعمال روی همه لایه‌های متنی"
},
"componentsStudioSidebarSceneEditSidebarContent": {
"panelTitle": "ویرایش صحنه",
"titleLabel": "عنوان",
"subtitleLabel": "زیرعنوان",
"textLabel": "متن {index}",
"textPlaceholder": "اینجا بنویسید…",
"imageLabel": "تصویر {index}",
"emptyStateTitle": "این صحنه هنوز محتوایی ندارد.",
"emptyStateHint": "برای شروع ویرایش، یک لایه متن اضافه کنید.",
"addTextLayer": "افزودن لایه متن",
"defaultText": "متن شما اینجا",
"replaceImage": "جایگزینی تصویر",
"uploadImage": "بارگذاری تصویر"
},
"componentsStudioSidebarTransitionsSidebarContent": {
"heading": "ترانزیشن‌ها",
"randomTransition": "ترانزیشن تصادفی",
"noTransition": "بدون ترانزیشن",
"exportNote": "ترانزیشن‌های اعمال‌شده پس از خروجی گرفتن روی همه صحنه‌ها نمایش داده می‌شوند."
},
"componentsStudioSidebarTtsSidebarContent": {
"title": "تبدیل متن به گفتار",
"comingSoon": "به‌زودی",
"description": "صداگذاری روایت را مستقیماً از روی متن خود در استودیو بسازید."
},
"componentsStudioSidebarWatermarkSidebarContent": {
"title": "واترمارک من",
"applyToAllScenes": "اعمال روی همه صحنه‌ها",
"uploadLogo": "لوگوی واترمارک خود را بارگذاری کنید",
"uploadHint": "PNG یا SVG، حداکثر ۲ مگابایت",
"position": "موقعیت",
"positionTopLeft": "بالا چپ",
"positionTopCenter": "بالا وسط",
"positionTopRight": "بالا راست",
"positionMiddleLeft": "میانه چپ",
"positionCenter": "وسط",
"positionMiddleRight": "میانه راست",
"positionBottomLeft": "پایین چپ",
"positionBottomCenter": "پایین وسط",
"positionBottomRight": "پایین راست",
"opacity": "شفافیت",
"opacityAriaLabel": "شفافیت واترمارک"
},
"componentsStudioTimelineAudioTrack": {
"emptyState": "بدون صدا — برای افزودن کلیک کنید"
},
"componentsStudioTimelineSceneBlock": {
"resizeDuration": "تغییر مدت‌زمان {name}"
},
"componentsStudioTimelineSceneThumbnailBlock": {
"duplicateScene": "تکثیر {name}",
"deleteScene": "حذف {name}",
"resizeSceneDuration": "تغییر مدت زمان {name}",
"sceneNameLabel": "نام صحنه",
"doubleClickToRename": "برای تغییر نام، دوبار کلیک کنید"
},
"componentsStudioTimelineSceneThumbnailStrip": {
"browseScenes": "مرور صحنه‌ها",
"addScene": "افزودن صحنه"
},
"componentsStudioTimelineTimeRuler": {
"rulerAriaLabel": "خط‌کش زمان — برای جابه‌جایی کلیک کنید"
},
"componentsStudioTimelineTimelineActionRow": {
"addTextToSpeech": "افزودن تبدیل متن به گفتار",
"addAudio": "افزودن صدا"
},
"componentsStudioTimelineTimelineControlBar": {
"copyLayer": "کپی لایه",
"deleteLayer": "حذف لایه",
"stop": "توقف",
"preview": "پیش‌نمایش",
"previewFromStart": "پیش‌نمایش از ابتدا",
"seekToStart": "رفتن به ابتدا",
"zoomOut": "کوچک‌نمایی",
"zoomIn": "بزرگ‌نمایی",
"timelineZoom": "بزرگ‌نمایی خط زمان"
},
"componentsStudioTimelineTimelineQuickActions": {
"addTextToSpeech": "افزودن تبدیل متن به گفتار",
"addAudio": "افزودن صدا"
},
"componentsStudioVideoCanvasArea": {
"loading": "در حال بارگذاری بوم…",
"editingNotice": "شما در حالت ویرایش هستید — ممکن است ظاهر متفاوت به نظر برسد. برای دیدن نتیجه نهایی روی <preview>پیش‌نمایش</preview> بزنید."
},
"componentsStudioVideoStudioSidebarDock": {
"scenes": "صحنه‌ها",
"audio": "صدا",
"textToSpeech": "تبدیل متن به گفتار",
"colors": "رنگ‌ها",
"transitions": "گذارها",
"font": "فونت",
"myWatermark": "واترمارک من",
"toolsNavLabel": "ابزارهای استودیو",
"guideMe": "راهنمایی‌ام کن",
"guideComingSoon": "👋 راهنما به‌زودی ارائه می‌شود!",
"keyboardShortcuts": "میان‌برهای صفحه‌کلید",
"keyboardShortcutsComingSoon": "میان‌برهای صفحه‌کلید به‌زودی ارائه می‌شوند!"
},
"componentsStudioVideoStudioTopBar": {
"snapshotSaved": "اسنپ‌شات ذخیره شد!",
"canvasNotReady": "بوم آماده نیست. دوباره تلاش کنید.",
"homeLink": "خانه فلت‌رندر",
"breadcrumb": "مسیر",
"myProjects": "پروژه‌های من",
"projectName": "نام پروژه",
"undo": "واگرد",
"redo": "ازنو",
"stop": "توقف",
"preview": "پیش‌نمایش",
"takeSnapshot": "گرفتن اسنپ‌شات",
"export": "خروجی گرفتن"
},
"componentsStudioVideoStudioTopBarSaveBadge": {
"savingTitle": "در حال ذخیره…",
"savingLabel": "در حال ذخیره",
"errorTitle": "ذخیره ناموفق بود",
"errorLabel": "ذخیره ناموفق بود",
"local": "محلی",
"saved": "ذخیره شد ✓"
},
"componentsStudioVideoStudioTopBarTextControls": {
"groupLabel": "ویژگی‌های لایه متن",
"fontFamily": "نوع قلم",
"fontSize": "اندازه قلم",
"bold": "ضخیم",
"italic": "مورب",
"textColor": "رنگ متن"
},
"componentsStudioVideoVideoNewPresetCard": {
"useTemplate": "استفاده از قالب"
},
"componentsStudioVideoVideoProjectNewContent": {
"breadcrumbCreate": "ساخت ویدیوی جدید",
"heading": "برای شروع ساخت، یکی از گزینه‌ها را انتخاب کنید",
"selectScenesTitle": "انتخاب صحنه‌ها",
"selectScenesDescription": "صحنه‌ها را مرور کنید و پروژه‌تان را از ابتدا بسازید",
"createWithAiTitle": "ساخت با هوش مصنوعی",
"createWithAiDescription": "ایده‌ها یا متن خود را به‌سادگی به ویدیوهای ساخته‌شده با هوش مصنوعی تبدیل کنید",
"aiProjectName": "پروژه ویدیویی هوش مصنوعی",
"or": "یا",
"startWithPresets": "شروع با قالب‌های آماده",
"searchPresetsPlaceholder": "جستجوی قالب‌های آماده...",
"newVideoName": "ویدیوی جدید"
},
"adminAi": {
"pageTitle": "محتوای سئو با هوش مصنوعی",
"pageDesc": "OpenAI را پیکربندی کنید و از روی یک توضیح، مقاله‌های بهینه‌شده برای سئو بسازید.",
"settingsTitle": "پیکربندی OpenAI",
"settingsDesc": "کلید API شما به‌صورت امن ذخیره می‌شود و هرگز به‌طور کامل نمایش داده نمی‌شود. در صورت نیاز، آدرس پایه را به یک سرویس سازگار با OpenAI و در‌دسترس تنظیم کنید.",
"apiKeyLabel": "کلید API",
"apiKeyPlaceholder": "sk-… (برای حفظ مقدار فعلی خالی بگذارید)",
"baseUrlLabel": "آدرس پایه",
"modelLabel": "مدل",
"enabledLabel": "فعال‌سازی تولید با هوش مصنوعی",
"saveSettings": "ذخیره تنظیمات",
"saving": "در حال ذخیره…",
"settingsSaved": "تنظیمات ذخیره شد",
"settingsError": "ذخیره تنظیمات ناموفق بود",
"keyConfigured": "کلید API تنظیم شده است",
"noKey": "کلید API تنظیم نشده است",
"generateTitle": "تولید مقاله سئو",
"generateDesc": "موضوع و متادیتا را توصیف کنید تا هوش مصنوعی یک پست آماده‌ی سئو بنویسد.",
"descriptionLabel": "توضیح / خلاصه",
"descriptionPlaceholder": "این صفحه/محصول درباره چیست؟ نکات کلیدی، لحن، اهداف…",
"titleLabel": "عنوان پیشنهادی (اختیاری)",
"typeLabel": "نوع محتوا (اختیاری)",
"typePlaceholder": "مثلاً قالب ویدیویی",
"tagsLabel": "برچسب‌ها (جدا‌شده با کاما، اختیاری)",
"keywordLabel": "کلیدواژه اصلی (اختیاری)",
"audienceLabel": "مخاطب (اختیاری)",
"localeLabel": "زبان",
"localeFa": "فارسی",
"localeEn": "انگلیسی",
"generate": "تولید",
"generating": "در حال تولید…",
"generateError": "تولید ناموفق بود",
"resultTitle": "مقاله تولیدشده",
"fTitle": "عنوان",
"fSlug": "نامک",
"fMetaTitle": "عنوان متا",
"fMetaDesc": "توضیحات متا",
"fKeywords": "کلیدواژه‌ها",
"fShortDesc": "توضیح کوتاه",
"fContent": "محتوا (HTML)",
"preview": "پیش‌نمایش",
"publishNow": "انتشار فوری",
"saveAsBlog": "ذخیره به‌عنوان پست بلاگ",
"savedAsBlog": "به‌عنوان پست بلاگ ذخیره شد",
"saveError": "ذخیره پست ناموفق بود",
"mustConfigure": "پیش از تولید، OpenAI را در بالا پیکربندی و فعال کنید."
}
}
}
+6 -6
View File
@@ -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() {
+6 -6
View File
@@ -1,7 +1,7 @@
<svg width="32" height="32" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" rx="9" fill="#2563EB"/>
<path d="M12 12.5L12 27.5L24.5 20L12 12.5Z" fill="white"/>
<rect x="27" y="13" width="7" height="2.5" rx="1.25" fill="white" fill-opacity="0.9"/>
<rect x="27" y="18.75" width="5.5" height="2.5" rx="1.25" fill="white" fill-opacity="0.75"/>
<rect x="27" y="24.5" width="4" height="2.5" rx="1.25" fill="white" fill-opacity="0.6"/>
<svg width="32" height="32" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" rx="12" fill="#2563EB"/>
<rect x="16" y="13" width="3.6" height="22" rx="1.8" fill="white"/>
<rect x="16" y="13" width="16" height="3.6" rx="1.8" fill="white"/>
<rect x="16" y="22.2" width="11" height="3.6" rx="1.8" fill="white" fill-opacity="0.75"/>
<path d="M30 29L35.5 32L30 35Z" fill="white" fill-opacity="0.9"/>
</svg>

Before

Width:  |  Height:  |  Size: 494 B

After

Width:  |  Height:  |  Size: 459 B

+47
View File
@@ -0,0 +1,47 @@
// One-off: merge workflow localization output into messages/{fa,en}.json under "auto",
// then report any auto.* namespaces referenced in src/ but missing from messages (orphans
// from failed batches that edited files without returning keys).
const fs = require("fs");
const path = require("path");
const cp = require("child_process");
const ROOT = path.resolve(__dirname, "..");
const outFile = process.argv[2];
if (!outFile) { console.error("usage: node merge-i18n.js <workflow-output-file>"); process.exit(1); }
// 1. Extract the result JSON from the workflow output file (whole file is valid JSON).
const raw = fs.readFileSync(outFile, "utf8");
const parsed = JSON.parse(raw);
const result = parsed.result || parsed;
const localized = result.localized || [];
console.log(`workflow result: localized=${localized.length} skipped=${(result.skipped||[]).length}`);
// 2. Merge into messages, preserving existing keys; create "auto" namespace.
for (const locale of ["fa", "en"]) {
const file = path.join(ROOT, "messages", `${locale}.json`);
const msg = JSON.parse(fs.readFileSync(file, "utf8"));
msg.auto = msg.auto || {};
let added = 0;
for (const item of localized) {
if (!item.pathKey) continue;
const payload = locale === "fa" ? item.fa : item.en;
if (payload && typeof payload === "object") { msg.auto[item.pathKey] = payload; added++; }
}
fs.writeFileSync(file, JSON.stringify(msg, null, 2) + "\n");
console.log(`${locale}.json: merged ${added} namespaces (auto.* total=${Object.keys(msg.auto).length})`);
}
// 3. Find auto.* namespaces referenced in src but missing from merged en.json → orphans.
const en = JSON.parse(fs.readFileSync(path.join(ROOT, "messages", "en.json"), "utf8"));
const present = new Set(Object.keys(en.auto || {}));
const grep = cp.spawnSync(
"grep",
["-rhoE", "(useTranslations|getTranslations)\\(\"auto\\.[a-zA-Z0-9]+\"", path.join(ROOT, "src")],
{ encoding: "utf8" }
);
const referenced = new Set();
for (const m of (grep.stdout || "").matchAll(/auto\.([a-zA-Z0-9]+)/g)) referenced.add(m[1]);
const orphans = [...referenced].filter((ns) => !present.has(ns));
console.log(`\nreferenced auto.* namespaces: ${referenced.size}`);
console.log(`ORPHANS (referenced but missing keys): ${orphans.length}`);
orphans.forEach((o) => console.log(" - auto." + o));
@@ -0,0 +1,177 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using FlatRender.ContentSvc.Domain.Entities;
using FlatRender.ContentSvc.Infrastructure.Data;
using FlatRender.ContentSvc.Models;
using Microsoft.EntityFrameworkCore;
namespace FlatRender.ContentSvc.Application.Services;
/// <summary>Thrown for expected/config errors (missing key, provider error) → mapped to 400 by the controller.</summary>
public class AiConfigException(string message) : Exception(message);
public class AiContentService(ContentDbContext db, IHttpClientFactory httpFactory)
{
public static readonly Guid DefaultTenant = Guid.Parse("00000000-0000-0000-0000-000000000001");
// ── Settings ──────────────────────────────────────────────────────────────
public async Task<AiSettings> GetRawAsync(Guid tenantId)
{
return await db.AiSettings.FirstOrDefaultAsync(x => x.TenantId == tenantId)
?? new AiSettings { TenantId = tenantId };
}
public async Task<AiSettingsResponse> GetSettingsAsync(Guid tenantId)
{
var s = await GetRawAsync(tenantId);
var key = s.ApiKey;
var has = !string.IsNullOrWhiteSpace(key);
var masked = has ? $"••••••••{key![Math.Max(0, key.Length - 4)..]}" : null;
return new AiSettingsResponse(s.Provider, s.BaseUrl, s.Model, s.Enabled, has, masked,
s.UpdatedAt == default ? null : s.UpdatedAt);
}
public async Task<AiSettingsResponse> UpdateSettingsAsync(Guid tenantId, UpdateAiSettingsRequest req)
{
var s = await db.AiSettings.FirstOrDefaultAsync(x => x.TenantId == tenantId);
var isNew = s is null;
s ??= new AiSettings { TenantId = tenantId };
if (req.Provider is { } p) s.Provider = p;
if (req.BaseUrl is { } b && !string.IsNullOrWhiteSpace(b)) s.BaseUrl = b.TrimEnd('/');
if (req.Model is { } m && !string.IsNullOrWhiteSpace(m)) s.Model = m;
if (req.Enabled is { } e) s.Enabled = e;
// ApiKey: null = leave unchanged; non-null (incl. "") = set/clear.
if (req.ApiKey is not null) s.ApiKey = string.IsNullOrWhiteSpace(req.ApiKey) ? null : req.ApiKey.Trim();
s.UpdatedAt = DateTime.UtcNow;
if (isNew) db.AiSettings.Add(s);
await db.SaveChangesAsync();
return await GetSettingsAsync(tenantId);
}
// ── Generation ──────────────────────────────────────────────────────────────
public async Task<SeoPostResponse> GenerateSeoPostAsync(Guid tenantId, GenerateSeoPostRequest req, CancellationToken ct)
{
var s = await GetRawAsync(tenantId);
if (!s.Enabled) throw new AiConfigException("AI generation is disabled. Enable it in AI settings.");
if (string.IsNullOrWhiteSpace(s.ApiKey)) throw new AiConfigException("No OpenAI API key configured. Add one in AI settings.");
if (string.IsNullOrWhiteSpace(req.Description)) throw new AiConfigException("A description is required.");
var locale = (req.Locale ?? "fa").ToLowerInvariant();
var langName = locale == "en" ? "English" : "Persian (Farsi)";
var system =
"You are a senior SEO content strategist and copywriter. Given a product/page description and metadata, " +
"write an original, engaging, well-structured, SEO-optimized article. " +
"Return ONLY a single valid JSON object (no markdown, no code fences) with EXACTLY these keys: " +
"title, slug, meta_title, meta_description, keywords, short_description, content_html. " +
"Rules: " +
$"write all human-readable text in {langName}; " +
"slug must be a short lowercase ASCII (a-z, 0-9, hyphens) URL slug derived from the topic, even when the article is in Persian; " +
"meta_title <= 60 characters; meta_description <= 160 characters and compelling; " +
"keywords = array of 5-8 relevant search keywords; " +
"short_description = 1-2 sentence summary; " +
"content_html = semantic HTML using <h2>, <h3>, <p>, <ul><li>, <strong> (no <html>/<body>/<h1>), 500-900 words, " +
"naturally incorporating the keywords, with a short intro, scannable sections, and a closing call to action.";
var sb = new StringBuilder();
sb.AppendLine("Write an SEO article for the following:");
if (!string.IsNullOrWhiteSpace(req.Title)) sb.AppendLine($"Working title: {req.Title}");
if (!string.IsNullOrWhiteSpace(req.Type)) sb.AppendLine($"Content type: {req.Type}");
if (req.Tags is { Length: > 0 }) sb.AppendLine($"Tags: {string.Join(", ", req.Tags)}");
if (!string.IsNullOrWhiteSpace(req.Keyword)) sb.AppendLine($"Primary target keyword: {req.Keyword}");
if (!string.IsNullOrWhiteSpace(req.Audience)) sb.AppendLine($"Target audience: {req.Audience}");
sb.AppendLine($"Description / brief:\n{req.Description}");
var payload = new
{
model = s.Model,
messages = new object[]
{
new { role = "system", content = system },
new { role = "user", content = sb.ToString() },
},
temperature = 0.7,
response_format = new { type = "json_object" },
};
var http = httpFactory.CreateClient("openai");
http.Timeout = TimeSpan.FromSeconds(90);
using var msg = new HttpRequestMessage(HttpMethod.Post, $"{s.BaseUrl.TrimEnd('/')}/chat/completions")
{
Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"),
};
msg.Headers.Authorization = new AuthenticationHeaderValue("Bearer", s.ApiKey);
HttpResponseMessage resp;
try { resp = await http.SendAsync(msg, ct); }
catch (Exception ex) { throw new AiConfigException($"Could not reach the AI provider: {ex.Message}"); }
var body = await resp.Content.ReadAsStringAsync(ct);
if (!resp.IsSuccessStatusCode)
throw new AiConfigException($"AI provider returned {(int)resp.StatusCode}: {Truncate(body, 300)}");
string contentJson;
try
{
using var doc = JsonDocument.Parse(body);
contentJson = doc.RootElement.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString() ?? "";
}
catch (Exception ex) { throw new AiConfigException($"Unexpected AI response shape: {ex.Message}"); }
return ParsePost(contentJson, req);
}
private static SeoPostResponse ParsePost(string contentJson, GenerateSeoPostRequest req)
{
try
{
using var doc = JsonDocument.Parse(contentJson);
var r = doc.RootElement;
string Str(string k) => r.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString()! : "";
string[] Keywords()
{
if (r.TryGetProperty("keywords", out var v))
{
if (v.ValueKind == JsonValueKind.Array)
return v.EnumerateArray().Where(e => e.ValueKind == JsonValueKind.String).Select(e => e.GetString()!).ToArray();
if (v.ValueKind == JsonValueKind.String)
return v.GetString()!.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
return [];
}
var title = Str("title");
if (string.IsNullOrWhiteSpace(title)) title = req.Title ?? "Untitled";
var slug = Slugify(Str("slug"));
if (string.IsNullOrWhiteSpace(slug)) slug = Slugify(req.Keyword ?? title);
if (string.IsNullOrWhiteSpace(slug)) slug = "post";
return new SeoPostResponse(
title,
slug,
Str("meta_title") is { Length: > 0 } mt ? mt : title,
Str("meta_description"),
Keywords(),
Str("short_description"),
Str("content_html")
);
}
catch (Exception ex) { throw new AiConfigException($"Could not parse AI content as JSON: {ex.Message}"); }
}
private static string Slugify(string s)
{
if (string.IsNullOrWhiteSpace(s)) return "";
s = s.Trim().ToLowerInvariant();
s = Regex.Replace(s, @"[^a-z0-9]+", "-").Trim('-');
return s.Length > 80 ? s[..80].Trim('-') : s;
}
private static string Truncate(string s, int n) => s.Length <= n ? s : s[..n] + "…";
}
@@ -0,0 +1,51 @@
using System.Security.Claims;
using FlatRender.ContentSvc.Application.Services;
using FlatRender.ContentSvc.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace FlatRender.ContentSvc.Controllers;
[ApiController]
[Route("v1/ai")]
[Authorize]
public class AiController(AiContentService svc) : ControllerBase
{
private Guid TenantId =>
Guid.TryParse(User.FindFirstValue("tenant_id"), out var t) ? t : AiContentService.DefaultTenant;
private bool IsAdmin =>
string.Equals(User.FindFirstValue("is_admin"), "true", StringComparison.OrdinalIgnoreCase) ||
string.Equals(User.FindFirstValue("is_tenant_admin"), "true", StringComparison.OrdinalIgnoreCase);
[HttpGet("settings")]
public async Task<IActionResult> GetSettings()
{
if (!IsAdmin) return Forbidden();
return Ok(await svc.GetSettingsAsync(TenantId));
}
[HttpPut("settings")]
public async Task<IActionResult> UpdateSettings([FromBody] UpdateAiSettingsRequest req)
{
if (!IsAdmin) return Forbidden();
return Ok(await svc.UpdateSettingsAsync(TenantId, req));
}
[HttpPost("seo-post")]
public async Task<IActionResult> GenerateSeoPost([FromBody] GenerateSeoPostRequest req, CancellationToken ct)
{
if (!IsAdmin) return Forbidden();
try
{
return Ok(await svc.GenerateSeoPostAsync(TenantId, req, ct));
}
catch (AiConfigException ex)
{
return BadRequest(new { error = new { code = "ai_error", message = ex.Message } });
}
}
private IActionResult Forbidden() =>
StatusCode(403, new { error = new { code = "forbidden", message = "Admin access required." } });
}
@@ -0,0 +1,13 @@
namespace FlatRender.ContentSvc.Domain.Entities;
/// <summary>Per-tenant OpenAI (or OpenAI-compatible) configuration for the AI content generator.</summary>
public class AiSettings
{
public Guid TenantId { get; set; }
public string Provider { get; set; } = "openai";
public string? ApiKey { get; set; }
public string BaseUrl { get; set; } = "https://api.openai.com/v1";
public string Model { get; set; } = "gpt-4o-mini";
public bool Enabled { get; set; }
public DateTime UpdatedAt { get; set; }
}
@@ -58,6 +58,9 @@ public class ContentDbContext(DbContextOptions<ContentDbContext> options) : DbCo
public DbSet<FavoriteFolder> FavoriteFolders => Set<FavoriteFolder>();
public DbSet<FavoriteContainer> FavoriteContainers => Set<FavoriteContainer>();
// AI
public DbSet<AiSettings> AiSettings => Set<AiSettings>();
protected override void OnModelCreating(ModelBuilder mb)
{
mb.HasDefaultSchema("content");
@@ -70,6 +73,13 @@ public class ContentDbContext(DbContextOptions<ContentDbContext> options) : DbCo
ConfigureScenes(mb);
ConfigureCharacters(mb);
ConfigureCms(mb);
// AI settings — snake_case convention maps columns (tenant_id, api_key, …).
mb.Entity<AiSettings>(e =>
{
e.ToTable("ai_settings");
e.HasKey(x => x.TenantId);
});
}
private static void ConfigureTaxonomy(ModelBuilder mb)
@@ -0,0 +1,44 @@
namespace FlatRender.ContentSvc.Models;
// ── AI settings ───────────────────────────────────────────────────────────────
/// <summary>Settings returned to the admin UI. The API key is never returned in full.</summary>
public record AiSettingsResponse(
string Provider,
string BaseUrl,
string Model,
bool Enabled,
bool HasApiKey,
string? ApiKeyMasked,
DateTime? UpdatedAt
);
public record UpdateAiSettingsRequest(
string? Provider,
string? ApiKey, // null = leave unchanged; "" = clear
string? BaseUrl,
string? Model,
bool? Enabled
);
// ── SEO post generation ─────────────────────────────────────────────────────
public record GenerateSeoPostRequest(
string Description,
string? Title,
string? Type, // e.g. "video template", "image template", "product"
string[]? Tags,
string? Locale, // "fa" (default) or "en"
string? Audience, // optional target-audience hint
string? Keyword // optional primary keyword to target
);
public record SeoPostResponse(
string Title,
string Slug,
string MetaTitle,
string MetaDescription,
string[] Keywords,
string ShortDescription,
string ContentHtml
);
@@ -50,7 +50,9 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
ValidIssuer = builder.Configuration["Jwt:Issuer"],
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<TaxonomyService>();
builder.Services.AddScoped<TemplateService>();
builder.Services.AddScoped<CmsService>();
builder.Services.AddScoped<AiContentService>();
// HTTP client for the OpenAI-compatible AI provider (base URL is per-tenant config).
builder.Services.AddHttpClient("openai");
// ── HTTP ──────────────────────────────────────────────────────────────────────
+1
View File
@@ -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())
@@ -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<Claim>
{
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))
@@ -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),
};
});
+7
View File
@@ -0,0 +1,7 @@
import { AiContentStudio } from "@/components/admin/AiContentStudio";
export const dynamic = "force-dynamic";
export default function AdminAiPage() {
return <AiContentStudio />;
}
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { AdminResource } from "@/components/admin/AdminResource";
import { blogsConfig } from "@/components/admin/admin-resources";
export default function Page() {
return <AdminResource config={blogsConfig} />;
}
@@ -0,0 +1,8 @@
"use client";
import { AdminResource } from "@/components/admin/AdminResource";
import { categoriesConfig } from "@/components/admin/admin-resources";
export default function Page() {
return <AdminResource config={categoriesConfig} />;
}
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { AdminResource } from "@/components/admin/AdminResource";
import { fontsConfig } from "@/components/admin/admin-resources";
export default function Page() {
return <AdminResource config={fontsConfig} />;
}
+27 -16
View File
@@ -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 (
<div className="min-h-screen bg-[#0c0e1a] text-gray-200">
<nav className="border-b border-[#1e2235] bg-[#0f1120] px-6 py-3">
<div className="mx-auto flex max-w-7xl items-center gap-6">
<span className="text-sm font-semibold text-white">FlatRender Admin</span>
<a
href="/admin/nodes"
className="text-sm text-gray-400 hover:text-white transition-colors"
>
Nodes
</a>
<a
href="/admin/renders"
className="text-sm text-gray-400 hover:text-white transition-colors"
>
Render Queue
</a>
<div className="mx-auto flex max-w-7xl flex-wrap items-center gap-x-5 gap-y-2">
<span className="text-sm font-semibold text-white">{t("brand")}</span>
{links.map((l) => (
<a
key={l.href}
href={l.href}
className="text-sm text-gray-400 transition-colors hover:text-white"
>
{l.label}
</a>
))}
<a
href="/dashboard"
className="ml-auto text-xs text-gray-500 hover:text-gray-300 transition-colors"
className="ml-auto text-xs text-gray-500 transition-colors hover:text-gray-300"
>
Back to Dashboard
{t("backToDashboard")}
</a>
</div>
</nav>
+5 -2
View File
@@ -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<V2NodeList>("/v1/nodes?pageSize=100");
const nodes = data?.items ?? [];
const t = await getTranslations("auto.appAdminNodesPage");
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-white">Render Nodes</h1>
<h1 className="text-xl font-semibold text-white">{t("title")}</h1>
<p className="mt-1 text-sm text-gray-500">
{nodes.length} node{nodes.length !== 1 ? "s" : ""} registered
{t("registered", { count: nodes.length })}
</p>
</div>
</div>
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { AdminResource } from "@/components/admin/AdminResource";
import { plansConfig } from "@/components/admin/admin-resources";
export default function Page() {
return <AdminResource config={plansConfig} />;
}
+16 -4
View File
@@ -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<V2RenderList>(`/v1/renders${qs}`);
const jobs = data?.items ?? [];
const total = data?.total ?? 0;
const t = await getTranslations("auto.appAdminRendersPage");
const steps = ["Queued", "Preparing", "Rendering", "Uploading", "Done", "Failed", "Cancelled"];
const stepLabels: Record<string, string> = {
Queued: t("stepQueued"),
Preparing: t("stepPreparing"),
Rendering: t("stepRendering"),
Uploading: t("stepUploading"),
Done: t("stepDone"),
Failed: t("stepFailed"),
Cancelled: t("stepCancelled"),
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-white">Render Queue</h1>
<p className="mt-1 text-sm text-gray-500">{total} total jobs</p>
<h1 className="text-xl font-semibold text-white">{t("title")}</h1>
<p className="mt-1 text-sm text-gray-500">{t("totalJobs", { total })}</p>
</div>
</div>
@@ -57,7 +69,7 @@ export default async function AdminRendersPage({
: "border-[#1e2235] text-gray-400 hover:text-white hover:border-[#2a3050]"
}`}
>
All
{t("filterAll")}
</a>
{steps.map((s) => (
<a
@@ -69,7 +81,7 @@ export default async function AdminRendersPage({
: "border-[#1e2235] text-gray-400 hover:text-white hover:border-[#2a3050]"
}`}
>
{s}
{stepLabels[s] ?? s}
</a>
))}
</div>
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { AdminResource } from "@/components/admin/AdminResource";
import { slidesConfig } from "@/components/admin/admin-resources";
export default function Page() {
return <AdminResource config={slidesConfig} />;
}
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { AdminResource } from "@/components/admin/AdminResource";
import { tagsConfig } from "@/components/admin/admin-resources";
export default function Page() {
return <AdminResource config={tagsConfig} />;
}
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { AdminResource } from "@/components/admin/AdminResource";
import { usersConfig } from "@/components/admin/admin-resources";
export default function Page() {
return <AdminResource config={usersConfig} />;
}
+12 -7
View File
@@ -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<Metadata> {
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 (
<main className="min-h-screen bg-neutral-50">
<Suspense
fallback={
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center py-20">
<AuthLoadingSpinner label="Loading..." />
<AuthLoadingSpinner label={t("loading")} />
</div>
}
>
+7 -5
View File
@@ -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() {
<div className="flex flex-1 flex-col">
{/* Page header */}
<header className="border-b border-gray-100 bg-white px-6 py-4">
<h1 className="font-heading text-xl font-bold text-neutral-900">Settings</h1>
<h1 className="font-heading text-xl font-bold text-neutral-900">{t("title")}</h1>
<p className="mt-0.5 text-sm text-neutral-500">
Manage your account, security, and notification preferences.
{t("subtitle")}
</p>
</header>
@@ -47,15 +49,15 @@ export default async function DashboardSettingsPage() {
{/* Danger zone */}
<div className="rounded-xl border border-red-100 bg-white p-6">
<h2 className="font-heading text-base font-semibold text-red-600">Danger zone</h2>
<h2 className="font-heading text-base font-semibold text-red-600">{t("dangerZoneTitle")}</h2>
<p className="mt-1 text-sm text-neutral-500">
Permanently delete your account and all your projects. This cannot be undone.
{t("dangerZoneDescription")}
</p>
<button
type="button"
className="mt-4 rounded-lg border border-red-200 px-4 py-2 text-sm font-semibold text-red-600 transition-colors hover:bg-red-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2"
>
Delete account
{t("deleteAccount")}
</button>
</div>
</div>
+6 -3
View File
@@ -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 (
<main className="flex min-h-screen flex-col items-center justify-center bg-white px-4 text-center">
<h1 className="font-heading text-2xl font-bold text-neutral-900 sm:text-3xl">
Something went wrong
{t("title")}
</h1>
<p className="mt-3 max-w-md text-sm text-neutral-600 sm:text-base">
An unexpected error occurred. Try reloading the page.
{t("description")}
</p>
<Button
type="button"
className="mt-8 bg-blue-600 hover:bg-blue-700"
onClick={() => reset()}
>
Reload page
{t("reloadButton")}
</Button>
</main>
);
+9 -6
View File
@@ -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<Metadata> {
const t = await getTranslations("auto.appImageMakerPage");
return createPageMetadata({
title: t("metaTitle"),
description: t("metaDescription"),
path: "/image-maker",
});
}
export default function ImageMakerPage() {
return (
+4 -2
View File
@@ -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({
>
<head>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="preconnect" href="https://picsum.photos" />
</head>
<body
className={`min-h-screen bg-white text-neutral-900 dark:bg-neutral-950 dark:text-neutral-50 ${
@@ -110,7 +110,9 @@ export default async function LocaleLayout({
}`}
>
<NextIntlClientProvider messages={messages} locale={locale}>
<SiteChrome>{children}</SiteChrome>
<DirectionProvider dir={isRtl ? "rtl" : "ltr"}>
<SiteChrome>{children}</SiteChrome>
</DirectionProvider>
</NextIntlClientProvider>
</body>
</html>
+7 -4
View File
@@ -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 (
<main className="flex min-h-screen flex-col items-center justify-center bg-white px-4 text-center">
<h1 className="font-heading text-2xl font-bold text-neutral-900 sm:text-3xl">
Page not found
{t("title")}
</h1>
<p className="mt-3 max-w-md text-sm text-neutral-600 sm:text-base">
The page you are looking for does not exist or may have been moved.
{t("description")}
</p>
<Button asChild className="mt-8 bg-blue-600 hover:bg-blue-700">
<Link href="/">Go home</Link>
<Link href="/">{t("goHome")}</Link>
</Button>
</main>
);
+9 -6
View File
@@ -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<Metadata> {
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.
@@ -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: () => (
<div className="flex h-screen w-screen items-center justify-center bg-gray-950 text-sm text-gray-500">
Loading editor
</div>
),
loading: () => <ImageEditorLoading />,
}
);
function ImageEditorLoading() {
const t = useTranslations("auto.appStudioImageProjectIdPage");
return (
<div className="flex h-screen w-screen items-center justify-center bg-gray-950 text-sm text-gray-500">
{t("loadingEditor")}
</div>
);
}
interface ImageStudioPageProps {
params: {
projectId: string;
+8 -7
View File
@@ -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<File | null>(null);
const [videoUrl, setVideoUrl] = useState<string | null>(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"
>
<ArrowLeft className="h-4 w-4" aria-hidden />
Back
{t("back")}
</Link>
<Scissors className="h-5 w-5 text-blue-500" aria-hidden />
<h1 className="font-heading text-lg font-semibold text-white">
Video Trimmer & Cropper
{t("title")}
</h1>
</div>
</header>
@@ -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: () => (
<div className="flex h-screen w-screen items-center justify-center bg-gray-900 text-sm text-gray-500">
Loading studio
</div>
),
loading: () => <VideoStudioLoading />,
}
);
function VideoStudioLoading() {
const t = useTranslations("auto.appStudioVideoProjectIdPage");
return (
<div className="flex h-screen w-screen items-center justify-center bg-gray-900 text-sm text-gray-500">
{t("loading")}
</div>
);
}
interface VideoStudioPageProps {
params: {
projectId: string;
+9 -6
View File
@@ -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<Metadata> {
const t = await getTranslations("auto.appVideoMakerPage");
return createPageMetadata({
title: t("metaTitle"),
description: t("metaDescription"),
path: "/video-maker",
});
}
export default function VideoMakerPage() {
return (
+50
View File
@@ -0,0 +1,50 @@
import { type NextRequest, NextResponse } from "next/server";
import { gatewayUrl } from "@/lib/api/gateway";
import { getAccessToken } from "@/lib/auth/session";
import { decodeJwt } from "@/lib/auth/jwt";
/**
* Forward an admin AI request to the V2 gateway, passing the request body through
* and returning the gateway's JSON response (status preserved). Admin-gated.
*/
export async function aiProxy(
req: NextRequest,
gatewayPath: string,
method: "GET" | "PUT" | "POST"
): Promise<NextResponse> {
const token = await getAccessToken();
if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const claims = decodeJwt(token);
const isAdmin =
String(claims?.is_admin) === "true" ||
claims?.is_admin === true ||
String(claims?.is_tenant_admin) === "true";
if (!isAdmin) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
let body: string | undefined;
if (method !== "GET") {
const json = await req.json().catch(() => ({}));
body = JSON.stringify(json);
}
const res = await fetch(gatewayUrl(gatewayPath), {
method,
cache: "no-store",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body,
});
const data = await res.json().catch(() => null);
if (!res.ok) {
const message =
(data && (data.error?.message ?? data.message)) || "Gateway error";
return NextResponse.json({ error: message }, { status: res.status });
}
return NextResponse.json(data ?? {}, { status: 200 });
}
+9
View File
@@ -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");
}
+10
View File
@@ -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");
}
+13
View File
@@ -0,0 +1,13 @@
import { type NextRequest } from "next/server";
import { aiProxy } from "../_aiProxy";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
return aiProxy(req, "/v1/ai/settings", "GET");
}
export async function PUT(req: NextRequest) {
return aiProxy(req, "/v1/ai/settings", "PUT");
}
@@ -0,0 +1,96 @@
import { type NextRequest, NextResponse } from "next/server";
import { gatewayUrl } from "@/lib/api/gateway";
import { getAccessToken } from "@/lib/auth/session";
import { decodeJwt } from "@/lib/auth/jwt";
export const dynamic = "force-dynamic";
/**
* Generic admin proxy: forwards GET/POST/PUT/DELETE for any admin resource to the V2
* gateway under /v1/<path>, attaching the admin's bearer token. Admin-gated server-side.
*
* /api/admin/resource/categories → /v1/categories
* /api/admin/resource/categories/<id> → /v1/categories/<id>
* /api/admin/resource/users?page=1 → /v1/users?page=1
*
* Query string is preserved.
*/
async function forward(
req: NextRequest,
path: string[],
method: "GET" | "POST" | "PUT" | "DELETE"
): Promise<NextResponse> {
const token = await getAccessToken();
if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const claims = decodeJwt(token);
const isAdmin =
String(claims?.is_admin) === "true" ||
claims?.is_admin === true ||
String(claims?.is_tenant_admin) === "true";
if (!isAdmin) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const search = req.nextUrl.search ?? "";
// Trailing slash on the collection root avoids the gateway's 307 redirect.
const joined = path.join("/");
const gwPath = `/v1/${joined}${path.length === 1 && method === "GET" ? "/" : ""}${search}`;
let body: string | undefined;
if (method === "POST" || method === "PUT") {
const json = await req.json().catch(() => ({}));
body = JSON.stringify(json);
}
const res = await fetch(gatewayUrl(gwPath), {
method,
cache: "no-store",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body,
redirect: "follow",
});
const text = await res.text();
const data = text ? safeJson(text) : null;
if (!res.ok) {
const errObj = data?.error;
const message =
(typeof errObj === "object" && errObj?.message) ||
(typeof errObj === "string" ? errObj : undefined) ||
data?.message ||
"Gateway error";
return NextResponse.json({ error: message }, { status: res.status });
}
return NextResponse.json(data ?? {}, { status: 200 });
}
interface GatewayResponse {
error?: { message?: string } | string;
message?: string;
[key: string]: unknown;
}
function safeJson(t: string): GatewayResponse | null {
try {
return JSON.parse(t) as GatewayResponse;
} catch {
return null;
}
}
export async function GET(req: NextRequest, ctx: { params: { path: string[] } }) {
return forward(req, ctx.params.path, "GET");
}
export async function POST(req: NextRequest, ctx: { params: { path: string[] } }) {
return forward(req, ctx.params.path, "POST");
}
export async function PUT(req: NextRequest, ctx: { params: { path: string[] } }) {
return forward(req, ctx.params.path, "PUT");
}
export async function DELETE(req: NextRequest, ctx: { params: { path: string[] } }) {
return forward(req, ctx.params.path, "DELETE");
}
@@ -0,0 +1,51 @@
import { type NextRequest } from "next/server";
// Deterministic, dependency-free placeholder image generator. Returns an SVG gradient
// derived from the `seed` query param so each placeholder is stable and distinct.
// Same-origin and offline — replaces external picsum.photos in restricted networks.
function hashString(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) {
h = (h << 5) - h + s.charCodeAt(i);
h |= 0; // force 32-bit
}
return Math.abs(h);
}
function clampDim(raw: string, fallback: number): number {
const n = parseInt(raw, 10);
if (!Number.isFinite(n)) return fallback;
return Math.min(2000, Math.max(1, n));
}
export function GET(
req: NextRequest,
context: { params: { width: string; height: string } }
) {
const { width, height } = context.params;
const w = clampDim(width, 400);
const h = clampDim(height, 300);
const seed = req.nextUrl.searchParams.get("seed") ?? "flatrender";
const hash = hashString(seed);
const hue1 = hash % 360;
const hue2 = (hue1 + 40 + (hash % 80)) % 360;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" preserveAspectRatio="xMidYMid slice">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="hsl(${hue1} 64% 58%)"/>
<stop offset="100%" stop-color="hsl(${hue2} 58% 42%)"/>
</linearGradient>
</defs>
<rect width="${w}" height="${h}" fill="url(#g)"/>
</svg>`;
return new Response(svg, {
headers: {
"Content-Type": "image/svg+xml",
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}
+244
View File
@@ -0,0 +1,244 @@
"use client";
import { useCallback, useEffect, useState, type ReactNode } from "react";
export interface FieldDef {
key: string;
label: string;
type?: "text" | "textarea" | "number" | "checkbox" | "select";
options?: { value: string; label: string }[];
required?: boolean;
placeholder?: string;
defaultValue?: string | number | boolean;
}
export interface ColumnDef {
key: string;
label: string;
render?: (row: Record<string, unknown>) => ReactNode;
}
export interface ResourceConfig {
title: string;
description?: string;
basePath: string; // e.g. "categories"
idKey?: string; // default "id"
listKey?: string; // wrap key, e.g. "items"; omit if response is a bare array
columns: ColumnDef[];
fields?: FieldDef[];
canCreate?: boolean;
canEdit?: boolean;
canDelete?: boolean;
rowActions?: (row: Record<string, unknown>, reload: () => void) => ReactNode;
}
const card = "rounded-xl border border-[#1e2235] bg-[#0f1120]";
const btn = "rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:opacity-50";
const btnGhost = "rounded-lg border border-[#262b40] px-3 py-1.5 text-xs text-gray-300 hover:bg-[#161a2e]";
const inputCls = "w-full rounded-lg border border-[#262b40] bg-[#0c0e1a] px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500";
export function AdminResource({ config }: { config: ResourceConfig }) {
const idKey = config.idKey ?? "id";
const [rows, setRows] = useState<Record<string, unknown>[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editing, setEditing] = useState<Record<string, unknown> | null>(null);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState<Record<string, unknown>>({});
const [saving, setSaving] = useState(false);
const url = (suffix = "") => `/api/admin/resource/${config.basePath}${suffix}`;
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(url(), { cache: "no-store" });
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? "Failed to load");
const list = config.listKey ? data?.[config.listKey] : data;
setRows(Array.isArray(list) ? list : (data?.items ?? []));
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load");
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.basePath, config.listKey]);
useEffect(() => {
reload();
}, [reload]);
const openCreate = () => {
const init: Record<string, unknown> = {};
config.fields?.forEach((f) => (init[f.key] = f.defaultValue ?? (f.type === "checkbox" ? false : "")));
setForm(init);
setCreating(true);
setEditing(null);
};
const openEdit = (row: Record<string, unknown>) => {
const init: Record<string, unknown> = {};
config.fields?.forEach((f) => (init[f.key] = row[f.key] ?? (f.type === "checkbox" ? false : "")));
setForm(init);
setEditing(row);
setCreating(false);
};
const closeForm = () => {
setCreating(false);
setEditing(null);
setForm({});
};
const submit = async () => {
setSaving(true);
setError(null);
try {
const isEdit = !!editing;
const res = await fetch(isEdit ? url(`/${editing![idKey]}`) : url(), {
method: isEdit ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const data = await res.json().catch(() => null);
if (!res.ok) throw new Error(data?.error ?? "Save failed");
closeForm();
reload();
} catch (e) {
setError(e instanceof Error ? e.message : "Save failed");
} finally {
setSaving(false);
}
};
const remove = async (row: Record<string, unknown>) => {
if (!confirm(`Delete this ${config.title.replace(/s$/, "").toLowerCase()}?`)) return;
const res = await fetch(url(`/${row[idKey]}`), { method: "DELETE" });
if (res.ok) reload();
else {
const d = await res.json().catch(() => null);
setError(d?.error ?? "Delete failed");
}
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-white">{config.title}</h1>
{config.description && <p className="mt-1 text-sm text-gray-400">{config.description}</p>}
</div>
{config.canCreate && config.fields && (
<button className={btn} onClick={openCreate}>+ New</button>
)}
</div>
{error && <p className="rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-300">{error}</p>}
<div className={`${card} overflow-hidden`}>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#1e2235] text-left text-xs text-gray-500">
{config.columns.map((c) => (
<th key={c.key} className="px-4 py-3 font-medium">{c.label}</th>
))}
{(config.canEdit || config.canDelete || config.rowActions) && (
<th className="px-4 py-3 text-right font-medium">Actions</th>
)}
</tr>
</thead>
<tbody>
{loading ? (
<tr><td className="px-4 py-8 text-center text-gray-500" colSpan={99}>Loading</td></tr>
) : rows.length === 0 ? (
<tr><td className="px-4 py-8 text-center text-gray-500" colSpan={99}>No records.</td></tr>
) : (
rows.map((row, i) => (
<tr key={String(row[idKey] ?? i)} className="border-b border-[#161a2e] hover:bg-[#12152a]">
{config.columns.map((c) => (
<td key={c.key} className="px-4 py-3 text-gray-200">
{c.render ? c.render(row) : formatCell(row[c.key])}
</td>
))}
{(config.canEdit || config.canDelete || config.rowActions) && (
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
{config.rowActions?.(row, reload)}
{config.canEdit && config.fields && (
<button className={btnGhost} onClick={() => openEdit(row)}>Edit</button>
)}
{config.canDelete && (
<button
className="rounded-lg border border-red-500/30 px-3 py-1.5 text-xs text-red-300 hover:bg-red-500/10"
onClick={() => remove(row)}
>
Delete
</button>
)}
</div>
</td>
)}
</tr>
))
)}
</tbody>
</table>
</div>
{(creating || editing) && config.fields && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={closeForm}>
<div className={`${card} w-full max-w-lg p-5`} onClick={(e) => e.stopPropagation()}>
<h2 className="text-sm font-semibold text-white">
{editing ? "Edit" : "New"} {config.title.replace(/s$/, "")}
</h2>
<div className="mt-4 grid max-h-[60vh] gap-3 overflow-y-auto pr-1">
{config.fields.map((f) => (
<div key={f.key}>
{f.type !== "checkbox" && (
<label className="mb-1 block text-xs font-medium text-gray-400">
{f.label}{f.required && <span className="text-red-400"> *</span>}
</label>
)}
{f.type === "textarea" ? (
<textarea className={`${inputCls} min-h-[80px]`} placeholder={f.placeholder}
value={String(form[f.key] ?? "")} onChange={(e) => setForm({ ...form, [f.key]: e.target.value })} />
) : f.type === "select" ? (
<select className={inputCls} value={String(form[f.key] ?? "")}
onChange={(e) => setForm({ ...form, [f.key]: e.target.value })}>
<option value=""></option>
{f.options?.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
) : f.type === "checkbox" ? (
<label className="flex items-center gap-2 text-sm text-gray-300">
<input type="checkbox" checked={!!form[f.key]} onChange={(e) => setForm({ ...form, [f.key]: e.target.checked })} />
{f.label}
</label>
) : (
<input type={f.type === "number" ? "number" : "text"} className={inputCls} placeholder={f.placeholder}
value={String(form[f.key] ?? "")}
onChange={(e) => setForm({ ...form, [f.key]: f.type === "number" ? Number(e.target.value) : e.target.value })} />
)}
</div>
))}
</div>
<div className="mt-5 flex items-center justify-end gap-2">
<button className={btnGhost} onClick={closeForm}>Cancel</button>
<button className={btn} onClick={submit} disabled={saving}>{saving ? "Saving…" : "Save"}</button>
</div>
</div>
</div>
)}
</div>
);
}
function formatCell(v: unknown): ReactNode {
if (v === null || v === undefined || v === "") return <span className="text-gray-600"></span>;
if (typeof v === "boolean") return v ? "✓" : "✗";
if (Array.isArray(v)) return v.join(", ");
if (typeof v === "object") return JSON.stringify(v).slice(0, 40);
const s = String(v);
return s.length > 60 ? s.slice(0, 60) + "…" : s;
}
+315
View File
@@ -0,0 +1,315 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
interface AiSettings {
provider: string;
base_url: string;
model: string;
enabled: boolean;
has_api_key: boolean;
api_key_masked: string | null;
}
interface SeoPost {
title: string;
slug: string;
meta_title: string;
meta_description: string;
keywords: string[];
short_description: string;
content_html: string;
}
const card = "rounded-xl border border-[#1e2235] bg-[#0f1120] p-5";
const label = "block text-xs font-medium text-gray-400 mb-1";
const input =
"w-full rounded-lg border border-[#262b40] bg-[#0c0e1a] px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500";
const btn =
"rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-50";
const btnGhost =
"rounded-lg border border-[#262b40] px-4 py-2 text-sm text-gray-300 hover:bg-[#161a2e] disabled:opacity-50";
export function AiContentStudio() {
const t = useTranslations("auto.adminAi");
const [settings, setSettings] = useState<AiSettings | null>(null);
const [apiKey, setApiKey] = useState("");
const [savingSettings, setSavingSettings] = useState(false);
const [settingsMsg, setSettingsMsg] = useState<string | null>(null);
// Generation form
const [description, setDescription] = useState("");
const [title, setTitle] = useState("");
const [type, setType] = useState("");
const [tags, setTags] = useState("");
const [keyword, setKeyword] = useState("");
const [audience, setAudience] = useState("");
const [locale, setLocale] = useState("fa");
const [generating, setGenerating] = useState(false);
const [genError, setGenError] = useState<string | null>(null);
const [post, setPost] = useState<SeoPost | null>(null);
const [publishNow, setPublishNow] = useState(false);
const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const loadSettings = useCallback(async () => {
const res = await fetch("/api/admin/ai/settings", { cache: "no-store" });
if (res.ok) setSettings(await res.json());
}, []);
useEffect(() => {
loadSettings();
}, [loadSettings]);
const saveSettings = async () => {
if (!settings) return;
setSavingSettings(true);
setSettingsMsg(null);
const res = await fetch("/api/admin/ai/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: settings.provider,
base_url: settings.base_url,
model: settings.model,
enabled: settings.enabled,
api_key: apiKey.trim() === "" ? null : apiKey.trim(),
}),
});
if (res.ok) {
setSettings(await res.json());
setApiKey("");
setSettingsMsg(t("settingsSaved"));
} else {
setSettingsMsg(t("settingsError"));
}
setSavingSettings(false);
};
const generate = async () => {
setGenerating(true);
setGenError(null);
setSaveMsg(null);
const res = await fetch("/api/admin/ai/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
description,
title: title || null,
type: type || null,
tags: tags ? tags.split(",").map((s) => s.trim()).filter(Boolean) : null,
keyword: keyword || null,
audience: audience || null,
locale,
}),
});
const data = await res.json().catch(() => null);
if (res.ok) setPost(data);
else setGenError(data?.error ?? t("generateError"));
setGenerating(false);
};
const saveAsBlog = async () => {
if (!post) return;
setSaving(true);
setSaveMsg(null);
const res = await fetch("/api/admin/ai/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
slug: post.slug,
title: post.title,
short_description: post.short_description,
content: post.content_html,
meta_title: post.meta_title,
meta_description: post.meta_description,
meta_keywords: post.keywords.join(", "),
include_in_site_map: true,
is_published: publishNow,
}),
});
setSaveMsg(res.ok ? t("savedAsBlog") : t("saveError"));
setSaving(false);
};
const setPostField = (k: keyof SeoPost, v: string) =>
setPost((p) => (p ? { ...p, [k]: v } : p));
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold text-white">{t("pageTitle")}</h1>
<p className="mt-1 text-sm text-gray-400">{t("pageDesc")}</p>
</div>
{/* ── Settings ─────────────────────────────────────────── */}
<section className={card}>
<h2 className="text-sm font-semibold text-white">{t("settingsTitle")}</h2>
<p className="mt-1 text-xs text-gray-500">{t("settingsDesc")}</p>
{settings && (
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className={label}>{t("apiKeyLabel")}</label>
<input
className={input}
type="password"
placeholder={settings.api_key_masked ?? t("apiKeyPlaceholder")}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
<p className="mt-1 text-[11px] text-gray-500">
{settings.has_api_key ? `${t("keyConfigured")}` : t("noKey")}
</p>
</div>
<div>
<label className={label}>{t("baseUrlLabel")}</label>
<input
className={input}
value={settings.base_url}
onChange={(e) => setSettings({ ...settings, base_url: e.target.value })}
/>
</div>
<div>
<label className={label}>{t("modelLabel")}</label>
<input
className={input}
value={settings.model}
onChange={(e) => setSettings({ ...settings, model: e.target.value })}
/>
</div>
<label className="flex items-center gap-2 text-sm text-gray-300">
<input
type="checkbox"
checked={settings.enabled}
onChange={(e) => setSettings({ ...settings, enabled: e.target.checked })}
/>
{t("enabledLabel")}
</label>
<div className="flex items-center gap-3 sm:col-span-2">
<button className={btn} onClick={saveSettings} disabled={savingSettings}>
{savingSettings ? t("saving") : t("saveSettings")}
</button>
{settingsMsg && <span className="text-xs text-gray-400">{settingsMsg}</span>}
</div>
</div>
)}
</section>
{/* ── Generator ────────────────────────────────────────── */}
<section className={card}>
<h2 className="text-sm font-semibold text-white">{t("generateTitle")}</h2>
<p className="mt-1 text-xs text-gray-500">{t("generateDesc")}</p>
{settings && !settings.enabled && (
<p className="mt-3 rounded-lg bg-amber-500/10 px-3 py-2 text-xs text-amber-300">
{t("mustConfigure")}
</p>
)}
<div className="mt-4 grid gap-4">
<div>
<label className={label}>{t("descriptionLabel")}</label>
<textarea
className={`${input} min-h-[100px]`}
placeholder={t("descriptionPlaceholder")}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className={label}>{t("titleLabel")}</label>
<input className={input} value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
<div>
<label className={label}>{t("typeLabel")}</label>
<input className={input} placeholder={t("typePlaceholder")} value={type} onChange={(e) => setType(e.target.value)} />
</div>
<div>
<label className={label}>{t("keywordLabel")}</label>
<input className={input} value={keyword} onChange={(e) => setKeyword(e.target.value)} />
</div>
<div>
<label className={label}>{t("audienceLabel")}</label>
<input className={input} value={audience} onChange={(e) => setAudience(e.target.value)} />
</div>
<div>
<label className={label}>{t("tagsLabel")}</label>
<input className={input} value={tags} onChange={(e) => setTags(e.target.value)} />
</div>
<div>
<label className={label}>{t("localeLabel")}</label>
<select className={input} value={locale} onChange={(e) => setLocale(e.target.value)}>
<option value="fa">{t("localeFa")}</option>
<option value="en">{t("localeEn")}</option>
</select>
</div>
</div>
<div className="flex items-center gap-3">
<button className={btn} onClick={generate} disabled={generating || !description.trim()}>
{generating ? t("generating") : t("generate")}
</button>
{genError && <span className="text-xs text-red-400">{genError}</span>}
</div>
</div>
</section>
{/* ── Result ───────────────────────────────────────────── */}
{post && (
<section className={card}>
<h2 className="text-sm font-semibold text-white">{t("resultTitle")}</h2>
<div className="mt-4 grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className={label}>{t("fTitle")}</label>
<input className={input} value={post.title} onChange={(e) => setPostField("title", e.target.value)} />
</div>
<div>
<label className={label}>{t("fSlug")}</label>
<input className={input} value={post.slug} onChange={(e) => setPostField("slug", e.target.value)} />
</div>
<div>
<label className={label}>{t("fMetaTitle")}</label>
<input className={input} value={post.meta_title} onChange={(e) => setPostField("meta_title", e.target.value)} />
</div>
<div>
<label className={label}>{t("fKeywords")}</label>
<input className={input} value={post.keywords.join(", ")} onChange={(e) => setPost({ ...post, keywords: e.target.value.split(",").map((s) => s.trim()).filter(Boolean) })} />
</div>
</div>
<div>
<label className={label}>{t("fMetaDesc")}</label>
<textarea className={`${input} min-h-[60px]`} value={post.meta_description} onChange={(e) => setPostField("meta_description", e.target.value)} />
</div>
<div>
<label className={label}>{t("fShortDesc")}</label>
<textarea className={`${input} min-h-[60px]`} value={post.short_description} onChange={(e) => setPostField("short_description", e.target.value)} />
</div>
<div>
<label className={label}>{t("fContent")}</label>
<textarea className={`${input} min-h-[200px] font-mono text-xs`} value={post.content_html} onChange={(e) => setPostField("content_html", e.target.value)} />
</div>
<div>
<label className={label}>{t("preview")}</label>
<div
className="prose prose-invert max-w-none rounded-lg border border-[#262b40] bg-white p-4 text-black"
dangerouslySetInnerHTML={{ __html: post.content_html }}
/>
</div>
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 text-sm text-gray-300">
<input type="checkbox" checked={publishNow} onChange={(e) => setPublishNow(e.target.checked)} />
{t("publishNow")}
</label>
<button className={btnGhost} onClick={saveAsBlog} disabled={saving}>
{saving ? t("saving") : t("saveAsBlog")}
</button>
{saveMsg && <span className="text-xs text-gray-400">{saveMsg}</span>}
</div>
</div>
</section>
)}
</div>
);
}
+12 -10
View File
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { apiFetch } from "@/lib/api/fetch";
import { useRouter } from "next/navigation";
@@ -31,6 +32,7 @@ function heartbeatAge(iso: string): string {
}
export function NodesTable({ nodes }: { nodes: V2Node[] }) {
const t = useTranslations("auto.componentsAdminNodesTable");
const router = useRouter();
const [loading, setLoading] = useState<Record<string, boolean>>({});
@@ -47,7 +49,7 @@ export function NodesTable({ nodes }: { nodes: V2Node[] }) {
if (nodes.length === 0) {
return (
<div className="rounded-xl border border-[#1e2235] bg-[#0f1120] px-6 py-16 text-center text-sm text-gray-500">
No nodes registered. Start the node agent on a render machine to see it here.
{t("emptyState")}
</div>
);
}
@@ -57,13 +59,13 @@ export function NodesTable({ nodes }: { nodes: V2Node[] }) {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#1e2235] bg-[#0f1120] text-left text-xs font-medium uppercase tracking-wider text-gray-500">
<th className="px-4 py-3">Node</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Slots</th>
<th className="px-4 py-3">Heartbeat</th>
<th className="px-4 py-3">Active Job</th>
<th className="px-4 py-3">Tags</th>
<th className="px-4 py-3">Actions</th>
<th className="px-4 py-3">{t("colNode")}</th>
<th className="px-4 py-3">{t("colStatus")}</th>
<th className="px-4 py-3">{t("colSlots")}</th>
<th className="px-4 py-3">{t("colHeartbeat")}</th>
<th className="px-4 py-3">{t("colActiveJob")}</th>
<th className="px-4 py-3">{t("colTags")}</th>
<th className="px-4 py-3">{t("colActions")}</th>
</tr>
</thead>
<tbody className="divide-y divide-[#1e2235] bg-[#0c0e1a]">
@@ -103,14 +105,14 @@ export function NodesTable({ nodes }: { nodes: V2Node[] }) {
disabled={loading[node.id] || node.status === "Offline"}
className="rounded px-2.5 py-1 text-xs text-yellow-300 border border-yellow-500/30 hover:bg-yellow-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Drain
{t("actionDrain")}
</button>
<button
onClick={() => action(node.id, "release")}
disabled={loading[node.id]}
className="rounded px-2.5 py-1 text-xs text-red-300 border border-red-500/30 hover:bg-red-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Release
{t("actionRelease")}
</button>
</div>
</td>
+13 -11
View File
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api/fetch";
import type { V2RenderJob } from "@/app/[locale]/admin/renders/page";
@@ -33,6 +34,7 @@ function relativeTime(iso: string): string {
}
export function RenderQueueTable({ jobs }: { jobs: V2RenderJob[] }) {
const t = useTranslations("auto.componentsAdminRenderQueueTable");
const router = useRouter();
const [loading, setLoading] = useState<Record<string, boolean>>({});
@@ -59,7 +61,7 @@ export function RenderQueueTable({ jobs }: { jobs: V2RenderJob[] }) {
if (jobs.length === 0) {
return (
<div className="rounded-xl border border-[#1e2235] bg-[#0f1120] px-6 py-16 text-center text-sm text-gray-500">
No render jobs found for the selected filter.
{t("emptyState")}
</div>
);
}
@@ -69,14 +71,14 @@ export function RenderQueueTable({ jobs }: { jobs: V2RenderJob[] }) {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#1e2235] bg-[#0f1120] text-left text-xs font-medium uppercase tracking-wider text-gray-500">
<th className="px-4 py-3">Job ID</th>
<th className="px-4 py-3">Project</th>
<th className="px-4 py-3">Step</th>
<th className="px-4 py-3">Progress</th>
<th className="px-4 py-3">Quality</th>
<th className="px-4 py-3">Node</th>
<th className="px-4 py-3">Created</th>
<th className="px-4 py-3">Actions</th>
<th className="px-4 py-3">{t("colJobId")}</th>
<th className="px-4 py-3">{t("colProject")}</th>
<th className="px-4 py-3">{t("colStep")}</th>
<th className="px-4 py-3">{t("colProgress")}</th>
<th className="px-4 py-3">{t("colQuality")}</th>
<th className="px-4 py-3">{t("colNode")}</th>
<th className="px-4 py-3">{t("colCreated")}</th>
<th className="px-4 py-3">{t("colActions")}</th>
</tr>
</thead>
<tbody className="divide-y divide-[#1e2235] bg-[#0c0e1a]">
@@ -133,7 +135,7 @@ export function RenderQueueTable({ jobs }: { jobs: V2RenderJob[] }) {
disabled={loading[job.id]}
className="rounded px-2.5 py-1 text-xs text-emerald-300 border border-emerald-500/30 hover:bg-emerald-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Retry
{t("actionRetry")}
</button>
)}
{canCancel && (
@@ -142,7 +144,7 @@ export function RenderQueueTable({ jobs }: { jobs: V2RenderJob[] }) {
disabled={loading[job.id]}
className="rounded px-2.5 py-1 text-xs text-red-300 border border-red-500/30 hover:bg-red-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Cancel
{t("actionCancel")}
</button>
)}
</div>
+170
View File
@@ -0,0 +1,170 @@
"use client";
import type { ResourceConfig } from "@/components/admin/AdminResource";
const badge = (ok: boolean, yes: string, no: string) =>
ok ? (
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[11px] text-emerald-300">{yes}</span>
) : (
<span className="rounded bg-gray-500/15 px-1.5 py-0.5 text-[11px] text-gray-400">{no}</span>
);
const banAction = (row: Record<string, unknown>, reload: () => void) => {
const banned = !!row.ban_account;
return (
<button
className={
banned
? "rounded-lg border border-emerald-500/30 px-3 py-1.5 text-xs text-emerald-300 hover:bg-emerald-500/10"
: "rounded-lg border border-red-500/30 px-3 py-1.5 text-xs text-red-300 hover:bg-red-500/10"
}
onClick={async () => {
const reason = banned ? "" : prompt("Ban reason?") ?? "";
if (!banned && !reason) return;
const res = await fetch(`/api/admin/resource/users/${row.id}/ban`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason: banned ? "unban" : reason, unbanned: banned }),
});
if (res.ok) reload();
}}
>
{banned ? "Unban" : "Ban"}
</button>
);
};
export const categoriesConfig: ResourceConfig = {
title: "Categories",
description: "Taxonomy used across templates and the public site.",
basePath: "categories",
canCreate: true,
canEdit: true,
canDelete: true,
columns: [
{ key: "name", label: "Name" },
{ key: "slug", label: "Slug" },
{ key: "is_active", label: "Active", render: (r) => badge(!!r.is_active, "active", "hidden") },
{ key: "sort", label: "Sort" },
],
fields: [
{ key: "name", label: "Name", required: true },
{ key: "slug", label: "Slug", required: true },
{ key: "description", label: "Description", type: "textarea" },
{ key: "image_url", label: "Image URL" },
{ key: "icon", label: "Icon" },
],
};
export const tagsConfig: ResourceConfig = {
title: "Tags",
description: "Keyword tags for templates and content.",
basePath: "tags",
listKey: "items",
canCreate: true,
canEdit: true,
canDelete: true,
columns: [
{ key: "name", label: "Name" },
{ key: "slug", label: "Slug" },
{ key: "is_active", label: "Active", render: (r) => badge(!!r.is_active, "active", "hidden") },
],
fields: [
{ key: "name", label: "Name", required: true },
{ key: "latin_name", label: "Latin name" },
{ key: "slug", label: "Slug", required: true },
{ key: "is_active", label: "Active", type: "checkbox", defaultValue: true },
],
};
export const fontsConfig: ResourceConfig = {
title: "Fonts",
description: "Fonts available in the studio editors.",
basePath: "fonts",
listKey: "items",
canCreate: true,
canEdit: true,
canDelete: true,
columns: [
{ key: "name", label: "Name" },
{ key: "family", label: "Family" },
{ key: "weight", label: "Weight" },
{ key: "style", label: "Style" },
],
fields: [
{ key: "name", label: "Name", required: true },
{ key: "original_name", label: "Original name" },
{ key: "system_name", label: "System name" },
{ key: "family", label: "Family" },
{ key: "weight", label: "Weight", type: "number" },
{ key: "style", label: "Style" },
],
};
export const blogsConfig: ResourceConfig = {
title: "Blog Posts",
description: "CMS articles (also created by the AI SEO generator).",
basePath: "blogs",
listKey: "items",
canCreate: true,
canEdit: true,
canDelete: true,
columns: [
{ key: "title", label: "Title" },
{ key: "slug", label: "Slug" },
{ key: "is_published", label: "Published", render: (r) => badge(!!r.is_published, "live", "draft") },
{ key: "view_count", label: "Views" },
],
fields: [
{ key: "title", label: "Title", required: true },
{ key: "slug", label: "Slug", required: true },
{ key: "short_description", label: "Short description", type: "textarea" },
{ key: "content", label: "Content (HTML)", type: "textarea", required: true },
{ key: "meta_title", label: "Meta title" },
{ key: "meta_description", label: "Meta description", type: "textarea" },
{ key: "meta_keywords", label: "Meta keywords" },
{ key: "is_published", label: "Published", type: "checkbox" },
{ key: "include_in_site_map", label: "Include in sitemap", type: "checkbox", defaultValue: true },
],
};
export const slidesConfig: ResourceConfig = {
title: "Home Slides",
description: "Hero/promo slides on the homepage.",
basePath: "slides",
canDelete: true,
columns: [
{ key: "title", label: "Title" },
{ key: "slide_type", label: "Type" },
{ key: "is_active", label: "Active", render: (r) => badge(!!r.is_active, "active", "hidden") },
],
};
export const usersConfig: ResourceConfig = {
title: "Users",
description: "Accounts in this tenant. Ban or unban below.",
basePath: "users",
listKey: "data",
columns: [
{ key: "email", label: "Email" },
{ key: "full_name", label: "Name" },
{ key: "is_admin", label: "Admin", render: (r) => badge(!!r.is_admin, "admin", "—") },
{ key: "register_mode", label: "Source" },
{ key: "ban_account", label: "Status", render: (r) => badge(!r.ban_account, "active", "banned") },
],
rowActions: banAction,
};
export const plansConfig: ResourceConfig = {
title: "Plans",
description: "Subscription plans (read-only view).",
basePath: "plans",
listKey: "data",
columns: [
{ key: "code", label: "Code" },
{ key: "name", label: "Name" },
{ key: "price_minor", label: "Price (minor)" },
{ key: "billing_period", label: "Period" },
{ key: "is_active", label: "Active", render: (r) => badge(!!r.is_active, "active", "off") },
],
};
+42 -32
View File
@@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import { useForm } from "react-hook-form";
import { useTranslations } from "next-intl";
import { AuthLoadingSpinner } from "@/components/auth/AuthLoadingSpinner";
import { authFormSchema, type AuthFormValues } from "@/components/auth/auth-schemas";
@@ -22,6 +23,7 @@ function safeNext(next: string | null): string {
}
export function AuthPageContent() {
const t = useTranslations("auto.componentsAuthAuthPageContent");
const router = useRouter();
const searchParams = useSearchParams();
const nextPath = safeNext(searchParams.get("next"));
@@ -107,7 +109,7 @@ export function AuthPageContent() {
const data = await res.json().catch(() => null);
if (!res.ok) {
setFormError(data?.error ?? "Something went wrong. Please try again.");
setFormError(data?.error ?? t("genericError"));
setSubmitting(false);
return;
}
@@ -115,8 +117,8 @@ export function AuthPageContent() {
if (data?.registered && !data?.user) {
setFormMessage(
data.verificationRequired
? "Account created. Check your email to verify, then sign in."
: "Account created. Please sign in."
? t("accountCreatedVerify")
: t("accountCreatedSignIn")
);
setActiveTab("sign-in");
reset();
@@ -127,7 +129,7 @@ export function AuthPageContent() {
router.replace(nextPath);
router.refresh();
} catch {
setFormError("Network error. Please try again.");
setFormError(t("networkError"));
setSubmitting(false);
}
};
@@ -146,9 +148,9 @@ export function AuthPageContent() {
});
// Always succeed (anti-enumeration)
setView("reset-confirm");
setFormMessage("If that email is registered, we sent a reset code.");
setFormMessage(t("resetCodeSent"));
} catch {
setFormError("Network error. Please try again.");
setFormError(t("networkError"));
} finally {
setSubmitting(false);
}
@@ -168,13 +170,13 @@ export function AuthPageContent() {
});
const data = await res.json().catch(() => null) as { error?: string } | null;
if (!res.ok) {
setFormError(data?.error ?? "Invalid or expired code.");
setFormError(data?.error ?? t("invalidCode"));
} else {
setFormMessage("Password updated. You can now sign in.");
setFormMessage(t("passwordUpdated"));
goBack();
}
} catch {
setFormError("Network error. Please try again.");
setFormError(t("networkError"));
} finally {
setSubmitting(false);
}
@@ -183,7 +185,7 @@ export function AuthPageContent() {
if (authLoading) {
return (
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center py-20">
<AuthLoadingSpinner label="Checking authentication..." />
<AuthLoadingSpinner label={t("checkingAuth")} />
</div>
);
}
@@ -194,12 +196,12 @@ export function AuthPageContent() {
<div className="mx-auto w-full max-w-md px-4 py-12 sm:py-16">
<div className="text-center">
<h1 className="font-heading text-2xl font-bold text-neutral-900">
{view === "forgot-password" ? "Reset your password" : "Enter reset code"}
{view === "forgot-password" ? t("resetTitle") : t("enterCodeTitle")}
</h1>
<p className="mt-2 text-sm text-neutral-600">
{view === "forgot-password"
? "We'll send a one-time code to your email."
: `Check your email for the code sent to ${resetEmail}`}
? t("resetSubtitle")
: t("enterCodeSubtitle", { email: resetEmail })}
</p>
</div>
@@ -208,7 +210,7 @@ export function AuthPageContent() {
<form onSubmit={handleForgotRequest} className="space-y-4" noValidate>
<div>
<label htmlFor="reset-email" className="block text-sm font-medium text-neutral-700">
Email address
{t("emailAddressLabel")}
</label>
<input
id="reset-email"
@@ -223,14 +225,14 @@ export function AuthPageContent() {
{formError && <p className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{formError}</p>}
<Button type="submit" className="w-full" disabled={submitting || !resetEmail}>
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
Send reset code
{t("sendResetCode")}
</Button>
</form>
) : (
<form onSubmit={handleResetConfirm} className="space-y-4" noValidate>
<div>
<label htmlFor="reset-otp" className="block text-sm font-medium text-neutral-700">
Reset code
{t("resetCodeLabel")}
</label>
<input
id="reset-otp"
@@ -240,13 +242,13 @@ export function AuthPageContent() {
onChange={(e) => setResetOtp(e.target.value)}
disabled={submitting}
required
placeholder="6-digit code"
placeholder={t("resetCodePlaceholder")}
className="mt-1.5 w-full rounded-lg border border-gray-100 bg-white px-3 py-2.5 text-sm text-neutral-900 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 disabled:opacity-50"
/>
</div>
<div>
<label htmlFor="new-password" className="block text-sm font-medium text-neutral-700">
New password
{t("newPasswordLabel")}
</label>
<input
id="new-password"
@@ -264,14 +266,14 @@ export function AuthPageContent() {
{formMessage && <p className="rounded-lg bg-primary-50 px-3 py-2 text-sm text-primary-700">{formMessage}</p>}
<Button type="submit" className="w-full" disabled={submitting || !resetOtp || !resetNewPassword}>
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
Set new password
{t("setNewPassword")}
</Button>
</form>
)}
</div>
<button type="button" onClick={goBack} className="mt-4 block w-full text-center text-sm text-neutral-500 hover:text-neutral-700 transition-colors">
Back to sign in
{t("backToSignIn")}
</button>
</div>
);
@@ -282,12 +284,12 @@ export function AuthPageContent() {
<div className="mx-auto w-full max-w-md px-4 py-12 sm:py-16">
<div className="text-center">
<h1 className="font-heading text-3xl font-bold text-neutral-900">
Welcome to FlatRender
{t("welcomeTitle")}
</h1>
<p className="mt-2 text-sm text-neutral-600">
{activeTab === "sign-in"
? "Sign in to continue to your dashboard"
: "Create a free account to get started"}
? t("signInSubtitle")
: t("signUpSubtitle")}
</p>
</div>
@@ -304,7 +306,7 @@ export function AuthPageContent() {
: "text-neutral-600 hover:text-neutral-900"
)}
>
{tab === "sign-in" ? "Sign In" : "Sign Up"}
{tab === "sign-in" ? t("signInTab") : t("signUpTab")}
</button>
))}
</div>
@@ -313,7 +315,7 @@ export function AuthPageContent() {
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div>
<label htmlFor="email" className="block text-sm font-medium text-neutral-700">
Email
{t("emailLabel")}
</label>
<input
id="email"
@@ -334,7 +336,7 @@ export function AuthPageContent() {
<div>
<div className="flex items-center justify-between">
<label htmlFor="password" className="block text-sm font-medium text-neutral-700">
Password
{t("passwordLabel")}
</label>
{activeTab === "sign-in" && (
<button
@@ -342,7 +344,7 @@ export function AuthPageContent() {
onClick={() => { setView("forgot-password"); setFormError(null); setFormMessage(null); }}
className="text-xs text-primary-600 hover:underline focus-visible:outline-none"
>
Forgot password?
{t("forgotPassword")}
</button>
)}
</div>
@@ -371,16 +373,24 @@ export function AuthPageContent() {
<Button type="submit" className="w-full" disabled={submitting}>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : null}
{activeTab === "sign-in" ? "Sign In" : "Create Account"}
{activeTab === "sign-in" ? t("signInTab") : t("createAccount")}
</Button>
</form>
</div>
<p className="mt-6 text-center text-xs text-neutral-500">
By continuing, you agree to our{" "}
<Link href="/terms" className="text-primary-600 hover:underline">Terms</Link>{" "}
and{" "}
<Link href="/privacy" className="text-primary-600 hover:underline">Privacy Policy</Link>.
{t.rich("legalNotice", {
terms: (chunks) => (
<Link href="/terms" className="text-primary-600 hover:underline">
{chunks}
</Link>
),
privacy: (chunks) => (
<Link href="/privacy" className="text-primary-600 hover:underline">
{chunks}
</Link>
),
})}
</p>
</div>
);
+19 -9
View File
@@ -2,6 +2,7 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
@@ -10,6 +11,7 @@ interface SupabaseSetupNoticeProps {
}
export function SupabaseSetupNotice({ nextPath }: SupabaseSetupNoticeProps) {
const t = useTranslations("auto.componentsAuthSupabaseSetupNotice");
const router = useRouter();
const isDev = process.env.NODE_ENV === "development";
const continueHref = nextPath?.startsWith("/") ? nextPath : "/dashboard";
@@ -18,15 +20,23 @@ export function SupabaseSetupNotice({ nextPath }: SupabaseSetupNoticeProps) {
<div className="mx-auto w-full max-w-md px-4 py-12 sm:py-16">
<div className="rounded-xl border border-amber-200 bg-amber-50 p-6 text-center shadow-sm">
<h1 className="font-heading text-xl font-bold text-neutral-900">
Supabase not configured
{t("title")}
</h1>
<p className="mt-3 text-sm text-neutral-600">
Copy <code className="rounded bg-white px-1.5 py-0.5 text-xs">.env.example</code>{" "}
to <code className="rounded bg-white px-1.5 py-0.5 text-xs">.env.local</code> and set{" "}
<code className="rounded bg-white px-1.5 py-0.5 text-xs">NEXT_PUBLIC_SUPABASE_URL</code>{" "}
and{" "}
<code className="rounded bg-white px-1.5 py-0.5 text-xs">NEXT_PUBLIC_SUPABASE_ANON_KEY</code>
, then restart the dev server.
{t.rich("instructions", {
envExample: () => (
<code className="rounded bg-white px-1.5 py-0.5 text-xs">.env.example</code>
),
envLocal: () => (
<code className="rounded bg-white px-1.5 py-0.5 text-xs">.env.local</code>
),
supabaseUrl: () => (
<code className="rounded bg-white px-1.5 py-0.5 text-xs">NEXT_PUBLIC_SUPABASE_URL</code>
),
supabaseAnonKey: () => (
<code className="rounded bg-white px-1.5 py-0.5 text-xs">NEXT_PUBLIC_SUPABASE_ANON_KEY</code>
),
})}
</p>
{isDev ? (
<Button
@@ -34,11 +44,11 @@ export function SupabaseSetupNotice({ nextPath }: SupabaseSetupNoticeProps) {
className="mt-6 w-full"
onClick={() => router.push(continueHref)}
>
Continue without signing in (dev only)
{t("continueDev")}
</Button>
) : (
<Button type="button" className="mt-6 w-full" asChild>
<Link href="/">Back to home</Link>
<Link href="/">{t("backToHome")}</Link>
</Button>
)}
</div>
@@ -1,24 +1,25 @@
"use client";
import { FolderOpen } from "lucide-react";
import { useTranslations } from "next-intl";
import { NewProjectMenu } from "@/components/dashboard/NewProjectMenu";
export function DashboardEmptyState() {
const t = useTranslations("auto.componentsDashboardDashboardEmptyState");
return (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-gray-200 bg-neutral-50 px-6 py-20 text-center">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-primary-50 text-primary-600">
<FolderOpen className="h-10 w-10" aria-hidden />
</div>
<h3 className="mt-6 font-heading text-xl font-semibold text-neutral-900">
No projects yet
{t("title")}
</h3>
<p className="mt-2 max-w-sm text-sm text-neutral-600">
Create a video, image, or trim project to see it here. Everything you
save appears in this workspace.
{t("description")}
</p>
<NewProjectMenu
triggerLabel="Create your first project"
triggerLabel={t("createFirstProject")}
triggerClassName="mt-8 gap-2"
align="center"
/>
@@ -1,4 +1,5 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Button } from "@/components/ui/button";
import { getUserProfile } from "@/lib/profiles";
@@ -16,6 +17,7 @@ interface DashboardPlanBadgeProps {
}
export async function DashboardPlanBadge({ userId }: DashboardPlanBadgeProps) {
const t = await getTranslations("auto.componentsDashboardDashboardPlanBadge");
const profile = await getUserProfile(userId);
return (
@@ -30,7 +32,7 @@ export async function DashboardPlanBadge({ userId }: DashboardPlanBadgeProps) {
</p>
{profile.plan !== "business" ? (
<Button size="sm" className="mt-3 w-full" asChild>
<Link href="/#pricing">Upgrade plan</Link>
<Link href="/#pricing">{t("upgradePlan")}</Link>
</Button>
) : null}
</>
@@ -1,6 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { DashboardEmptyState } from "@/components/dashboard/DashboardEmptyState";
import { DashboardTopBar } from "@/components/dashboard/DashboardTopBar";
@@ -19,6 +20,7 @@ export function DashboardProjectsSection({
projects = [],
isLoading = false,
}: DashboardProjectsSectionProps) {
const t = useTranslations("auto.componentsDashboardDashboardProjectsSection");
const [searchQuery, setSearchQuery] = useState("");
const filteredProjects = useMemo(() => {
@@ -42,7 +44,7 @@ export function DashboardProjectsSection({
<div className="flex-1 overflow-auto p-6">
<h2 className="font-heading text-xl font-bold text-neutral-900">
Recent Projects
{t("recentProjects")}
</h2>
{showEmpty && (
@@ -62,10 +64,10 @@ export function DashboardProjectsSection({
{showNoResults && (
<div className="mt-8 rounded-xl border border-dashed border-gray-200 bg-neutral-50 px-6 py-12 text-center">
<p className="font-heading text-lg font-semibold text-neutral-900">
No projects match your search
{t("noResultsTitle")}
</p>
<p className="mt-2 text-sm text-neutral-600">
Try a different keyword or clear the search bar.
{t("noResultsDescription")}
</p>
</div>
)}
@@ -1,5 +1,6 @@
import Link from "next/link";
import { Suspense } from "react";
import { getTranslations } from "next-intl/server";
import { LogoMark } from "@/components/ui/LogoMark";
import {
@@ -26,11 +27,12 @@ function getInitials(email: string, name?: string | null): string {
return email.slice(0, 2).toUpperCase();
}
export function DashboardSidebar({
export async function DashboardSidebar({
userEmail,
userName,
userId,
}: DashboardSidebarProps) {
const t = await getTranslations("auto.componentsDashboardDashboardSidebar");
const initials = getInitials(userEmail, userName);
return (
@@ -52,7 +54,7 @@ export function DashboardSidebar({
<div className="border-t border-gray-100 p-4">
<div className="mb-3 rounded-lg border border-gray-100 bg-neutral-50 p-3">
<p className="text-xs font-medium uppercase tracking-wide text-neutral-500">
Current plan
{t("currentPlan")}
</p>
<Suspense fallback={<DashboardPlanBadgeSkeleton />}>
<DashboardPlanBadge userId={userId} />
@@ -78,7 +80,7 @@ export function DashboardSidebar({
type="submit"
className="w-full rounded-lg px-3 py-2 text-left text-sm text-neutral-600 transition-colors hover:bg-neutral-50 hover:text-neutral-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2"
>
Sign out
{t("signOut")}
</button>
</form>
</div>
@@ -2,6 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import {
FolderOpen,
LayoutTemplate,
@@ -12,17 +13,18 @@ import {
import { cn } from "@/lib/utils";
const navItems = [
{ label: "My Projects", href: "/dashboard", icon: FolderOpen },
{ label: "Templates", href: "/templates", icon: LayoutTemplate },
{ label: "Upgrade", href: "/#pricing", icon: Zap },
{ label: "Settings", href: "/dashboard/settings", icon: Settings },
{ labelKey: "myProjects", href: "/dashboard", icon: FolderOpen },
{ labelKey: "templates", href: "/templates", icon: LayoutTemplate },
{ labelKey: "upgrade", href: "/#pricing", icon: Zap },
{ labelKey: "settings", href: "/dashboard/settings", icon: Settings },
] as const;
export function DashboardSidebarNav() {
const pathname = usePathname();
const t = useTranslations("auto.componentsDashboardDashboardSidebarNav");
return (
<nav className="flex-1 space-y-1 px-3 py-4" aria-label="Dashboard">
<nav className="flex-1 space-y-1 px-3 py-4" aria-label={t("navLabel")}>
{navItems.map((item) => {
const Icon = item.icon;
const isActive =
@@ -32,7 +34,7 @@ export function DashboardSidebarNav() {
return (
<Link
key={item.label}
key={item.labelKey}
href={item.href}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2",
@@ -42,7 +44,7 @@ export function DashboardSidebarNav() {
)}
>
<Icon className="h-4 w-4 shrink-0" aria-hidden />
{item.label}
{t(item.labelKey)}
</Link>
);
})}
+4 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { Search } from "lucide-react";
import { useTranslations } from "next-intl";
import { NewProjectMenu } from "@/components/dashboard/NewProjectMenu";
@@ -13,6 +14,8 @@ export function DashboardTopBar({
searchQuery,
onSearchChange,
}: DashboardTopBarProps) {
const t = useTranslations("auto.componentsDashboardDashboardTopBar");
return (
<header className="flex flex-col gap-4 border-b border-gray-100 bg-white px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
<label className="relative max-w-md flex-1">
@@ -24,7 +27,7 @@ export function DashboardTopBar({
type="search"
value={searchQuery}
onChange={(event) => onSearchChange(event.target.value)}
placeholder="Search projects..."
placeholder={t("searchPlaceholder")}
className="w-full rounded-lg border border-gray-100 bg-neutral-50 py-2.5 pl-10 pr-4 text-sm text-neutral-900 placeholder:text-neutral-400 focus-visible:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2"
/>
</label>
+8 -5
View File
@@ -3,6 +3,7 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
import { ChevronDown, Clapperboard, ImageIcon, Plus, Scissors } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
@@ -20,12 +21,14 @@ interface NewProjectMenuProps {
}
export function NewProjectMenu({
triggerLabel = "New Project",
triggerLabel,
triggerClassName,
align = "end",
}: NewProjectMenuProps) {
const t = useTranslations("auto.componentsDashboardNewProjectMenu");
const router = useRouter();
const [isCreating, setIsCreating] = useState(false);
const label = triggerLabel ?? t("newProject");
const createProject = async (type: ProjectType) => {
setIsCreating(true);
@@ -64,7 +67,7 @@ export function NewProjectMenu({
<DropdownMenuTrigger asChild>
<Button className={triggerClassName} disabled={isCreating}>
<Plus className="h-4 w-4" aria-hidden />
{isCreating ? "Creating" : triggerLabel}
{isCreating ? t("creating") : label}
<ChevronDown className="h-4 w-4 opacity-80" aria-hidden />
</Button>
</DropdownMenuTrigger>
@@ -74,21 +77,21 @@ export function NewProjectMenu({
onClick={() => router.push("/studio/video/new")}
>
<Clapperboard className="h-4 w-4 text-primary-600" />
Video Project
{t("videoProject")}
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer gap-2"
onClick={() => createProject("image")}
>
<ImageIcon className="h-4 w-4 text-violet-600" />
Image Project
{t("imageProject")}
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer gap-2"
onClick={() => createProject("trimmer")}
>
<Scissors className="h-4 w-4 text-amber-600" />
Trim/Crop Video
{t("trimCropVideo")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
+15 -13
View File
@@ -2,6 +2,7 @@
import { useCallback, useState } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { AnimatePresence, motion } from "framer-motion";
import { Copy, Download, ExternalLink, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
@@ -39,14 +40,14 @@ function statusBadgeClass(status: DashboardProject["status"]): string {
}
}
function statusLabel(status: DashboardProject["status"]): string {
function statusLabelKey(status: DashboardProject["status"]): "statusRendering" | "statusReady" | "statusDraft" {
switch (status) {
case "rendering":
return "Rendering";
return "statusRendering";
case "ready":
return "Ready";
return "statusReady";
default:
return "Draft";
return "statusDraft";
}
}
@@ -66,6 +67,7 @@ function typeBadgeClass(type: DashboardProject["type"]): string {
const fadeTransition = { duration: 0.25, ease: "easeOut" as const };
export function ProjectCard({ project }: ProjectCardProps) {
const t = useTranslations("auto.componentsDashboardProjectCard");
const studioPath = getProjectStudioPath(project);
const showRenderStatus = project.type === "video";
@@ -133,7 +135,7 @@ export function ProjectCard({ project }: ProjectCardProps) {
>
<Link href={studioPath}>
<ExternalLink className="h-3.5 w-3.5" />
Open in Studio
{t("openInStudio")}
</Link>
</Button>
{project.status === "ready" && project.renderUrl ? (
@@ -145,7 +147,7 @@ export function ProjectCard({ project }: ProjectCardProps) {
>
<a href={project.renderUrl} download>
<Download className="h-3.5 w-3.5" />
Download
{t("download")}
</a>
</Button>
) : null}
@@ -173,7 +175,7 @@ export function ProjectCard({ project }: ProjectCardProps) {
statusBadgeClass(project.status)
)}
>
{statusLabel(project.status)}
{t(statusLabelKey(project.status))}
</span>
) : null}
<span className="text-xs text-neutral-500">
@@ -185,7 +187,7 @@ export function ProjectCard({ project }: ProjectCardProps) {
<DropdownMenu>
<DropdownMenuTrigger
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2"
aria-label={`Actions for ${project.name}`}
aria-label={t("actionsFor", { name: project.name })}
>
<MoreHorizontal className="h-4 w-4" />
</DropdownMenuTrigger>
@@ -193,30 +195,30 @@ export function ProjectCard({ project }: ProjectCardProps) {
<DropdownMenuItem asChild>
<Link href={studioPath} className="gap-2">
<ExternalLink className="h-4 w-4" />
Open in Studio
{t("openInStudio")}
</Link>
</DropdownMenuItem>
{project.renderUrl ? (
<DropdownMenuItem asChild>
<a href={project.renderUrl} download className="gap-2">
<Download className="h-4 w-4" />
Download
{t("download")}
</a>
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2">
<Pencil className="h-4 w-4" />
Rename
{t("rename")}
</DropdownMenuItem>
<DropdownMenuItem className="gap-2">
<Copy className="h-4 w-4" />
Duplicate
{t("duplicate")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2 text-red-600 focus:text-red-600">
<Trash2 className="h-4 w-4" />
Delete
{t("delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { CreditCard, Loader2, Zap } from "lucide-react";
import { apiFetch } from "@/lib/api/fetch";
@@ -10,10 +11,10 @@ interface SettingsBillingProps {
plan: PlanId;
}
const PLAN_LABELS: Record<PlanId, string> = {
free: "Free",
pro: "Pro",
business: "Business",
const PLAN_LABEL_KEYS: Record<PlanId, string> = {
free: "planFree",
pro: "planPro",
business: "planBusiness",
};
const PLAN_COLORS: Record<PlanId, string> = {
@@ -22,32 +23,33 @@ const PLAN_COLORS: Record<PlanId, string> = {
business: "bg-violet-50 text-violet-700",
};
const PLAN_FEATURES: Record<PlanId, string[]> = {
free: ["5 projects", "720p export", "Community templates"],
pro: ["Unlimited projects", "4K export", "All templates", "Priority render queue", "Custom fonts"],
business: ["Everything in Pro", "Team seats", "White-label export", "API access", "Dedicated support"],
const PLAN_FEATURE_KEYS: Record<PlanId, string[]> = {
free: ["featureFree5Projects", "featureFree720pExport", "featureFreeCommunityTemplates"],
pro: ["featureProUnlimitedProjects", "featurePro4kExport", "featureProAllTemplates", "featureProPriorityRenderQueue", "featureProCustomFonts"],
business: ["featureBusinessEverythingInPro", "featureBusinessTeamSeats", "featureBusinessWhiteLabelExport", "featureBusinessApiAccess", "featureBusinessDedicatedSupport"],
};
export function SettingsBilling({ plan }: SettingsBillingProps) {
const t = useTranslations("auto.componentsDashboardSettingsSettingsBilling");
const isPaid = plan !== "free";
const [cancelling, setCancelling] = useState(false);
const [cancelled, setCancelled] = useState(false);
const [cancelError, setCancelError] = useState<string | null>(null);
const handleCancel = async () => {
if (!confirm("Cancel your plan? You'll keep access until the current period ends.")) return;
if (!confirm(t("cancelConfirm"))) return;
setCancelling(true);
setCancelError(null);
try {
const res = await apiFetch("/api/billing/cancel", { method: "POST" });
if (!res.ok) {
const data = (await res.json().catch(() => null)) as { error?: string } | null;
setCancelError(data?.error ?? "Failed to cancel plan. Please try again.");
setCancelError(data?.error ?? t("cancelFailed"));
} else {
setCancelled(true);
}
} catch {
setCancelError("Network error. Please try again.");
setCancelError(t("networkError"));
} finally {
setCancelling(false);
}
@@ -55,8 +57,8 @@ export function SettingsBilling({ plan }: SettingsBillingProps) {
return (
<div className="rounded-xl border border-gray-100 bg-white p-6">
<h2 className="font-heading text-base font-semibold text-neutral-900">Billing &amp; Plan</h2>
<p className="mt-1 text-sm text-neutral-500">Manage your subscription and payment method.</p>
<h2 className="font-heading text-base font-semibold text-neutral-900">{t("title")}</h2>
<p className="mt-1 text-sm text-neutral-500">{t("subtitle")}</p>
{/* Current plan card */}
<div className="mt-6 rounded-lg border border-gray-100 bg-neutral-50 p-4">
@@ -66,11 +68,11 @@ export function SettingsBilling({ plan }: SettingsBillingProps) {
<Zap className="h-5 w-5" aria-hidden />
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-neutral-500">Current plan</p>
<p className="text-xs font-medium uppercase tracking-wide text-neutral-500">{t("currentPlan")}</p>
<div className="flex items-center gap-2">
<p className="font-heading text-lg font-bold text-neutral-900">{PLAN_LABELS[plan]}</p>
<p className="font-heading text-lg font-bold text-neutral-900">{t(PLAN_LABEL_KEYS[plan])}</p>
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${PLAN_COLORS[plan]}`}>
{cancelled ? "Cancels at period end" : isPaid ? "Active" : "Free tier"}
{cancelled ? t("statusCancelsAtPeriodEnd") : isPaid ? t("statusActive") : t("statusFreeTier")}
</span>
</div>
</div>
@@ -81,17 +83,17 @@ export function SettingsBilling({ plan }: SettingsBillingProps) {
className="inline-flex items-center gap-1.5 rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
>
<Zap className="h-4 w-4" aria-hidden />
Upgrade
{t("upgrade")}
</a>
)}
</div>
{/* Features list */}
<ul className="mt-4 space-y-1.5">
{PLAN_FEATURES[plan].map((f) => (
<li key={f} className="flex items-center gap-2 text-sm text-neutral-600">
{PLAN_FEATURE_KEYS[plan].map((key) => (
<li key={key} className="flex items-center gap-2 text-sm text-neutral-600">
<span className="h-1.5 w-1.5 rounded-full bg-primary-500" aria-hidden />
{f}
{t(key)}
</li>
))}
</ul>
@@ -105,7 +107,7 @@ export function SettingsBilling({ plan }: SettingsBillingProps) {
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50"
>
<CreditCard className="h-4 w-4" aria-hidden />
Change plan
{t("changePlan")}
</a>
<button
type="button"
@@ -114,7 +116,7 @@ export function SettingsBilling({ plan }: SettingsBillingProps) {
className="inline-flex items-center gap-1.5 rounded-lg border border-red-200 px-3 py-1.5 text-sm font-medium text-red-600 transition-colors hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
{cancelling ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{cancelling ? "Cancelling" : "Cancel plan"}
{cancelling ? t("cancelling") : t("cancelPlan")}
</button>
</div>
)}
@@ -127,13 +129,13 @@ export function SettingsBilling({ plan }: SettingsBillingProps) {
{cancelled && (
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700">
Your plan has been cancelled. You&apos;ll keep access until the end of your billing period.
{t("cancelledNotice")}
</p>
)}
{!isPaid && (
<p className="mt-4 text-xs text-neutral-400">
Upgrade to unlock unlimited projects, 4K export, and premium templates.
{t("upgradeHint")}
</p>
)}
</div>
@@ -1,42 +1,44 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
interface Toggle {
id: string;
label: string;
description: string;
labelKey: string;
descriptionKey: string;
defaultOn: boolean;
}
const TOGGLES: Toggle[] = [
{
id: "render-complete",
label: "Render complete",
description: "Get notified when your video export finishes.",
labelKey: "renderCompleteLabel",
descriptionKey: "renderCompleteDescription",
defaultOn: true,
},
{
id: "project-shared",
label: "Project shared with you",
description: "When a team member shares a project.",
labelKey: "projectSharedLabel",
descriptionKey: "projectSharedDescription",
defaultOn: true,
},
{
id: "weekly-digest",
label: "Weekly digest",
description: "Summary of new templates and platform updates.",
labelKey: "weeklyDigestLabel",
descriptionKey: "weeklyDigestDescription",
defaultOn: false,
},
{
id: "product-news",
label: "Product news",
description: "New features, tips, and announcements.",
labelKey: "productNewsLabel",
descriptionKey: "productNewsDescription",
defaultOn: false,
},
];
export function SettingsNotifications() {
const t = useTranslations("auto.componentsDashboardSettingsSettingsNotifications");
const [prefs, setPrefs] = useState<Record<string, boolean>>(
Object.fromEntries(TOGGLES.map((t) => [t.id, t.defaultOn]))
);
@@ -55,15 +57,15 @@ export function SettingsNotifications() {
return (
<div className="rounded-xl border border-gray-100 bg-white p-6">
<h2 className="font-heading text-base font-semibold text-neutral-900">Notifications</h2>
<p className="mt-1 text-sm text-neutral-500">Choose which emails you receive from FlatRender.</p>
<h2 className="font-heading text-base font-semibold text-neutral-900">{t("title")}</h2>
<p className="mt-1 text-sm text-neutral-500">{t("subtitle")}</p>
<div className="mt-6 divide-y divide-gray-100">
{TOGGLES.map((item) => (
<div key={item.id} className="flex items-start justify-between gap-4 py-4 first:pt-0 last:pb-0">
<div>
<p className="text-sm font-medium text-neutral-900">{item.label}</p>
<p className="mt-0.5 text-xs text-neutral-500">{item.description}</p>
<p className="text-sm font-medium text-neutral-900">{t(item.labelKey)}</p>
<p className="mt-0.5 text-xs text-neutral-500">{t(item.descriptionKey)}</p>
</div>
<button
type="button"
@@ -90,9 +92,9 @@ export function SettingsNotifications() {
onClick={save}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
>
Save preferences
{t("savePreferences")}
</button>
{saved && <span className="text-sm text-green-600">Saved!</span>}
{saved && <span className="text-sm text-green-600">{t("saved")}</span>}
</div>
</div>
);
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { User } from "lucide-react";
interface SettingsProfileProps {
@@ -9,6 +10,7 @@ interface SettingsProfileProps {
}
export function SettingsProfile({ email, displayName }: SettingsProfileProps) {
const t = useTranslations("auto.componentsDashboardSettingsSettingsProfile");
const [name, setName] = useState(displayName ?? "");
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
@@ -27,12 +29,12 @@ export function SettingsProfile({ email, displayName }: SettingsProfileProps) {
});
const data = (await res.json().catch(() => null)) as { error?: string } | null;
if (!res.ok) {
setMessage({ type: "error", text: data?.error ?? "Could not update profile." });
setMessage({ type: "error", text: data?.error ?? t("updateFailed") });
} else {
setMessage({ type: "success", text: "Profile updated successfully." });
setMessage({ type: "success", text: t("updateSuccess") });
}
} catch {
setMessage({ type: "error", text: "Network error. Please try again." });
setMessage({ type: "error", text: t("networkError") });
} finally {
setSaving(false);
}
@@ -40,8 +42,8 @@ export function SettingsProfile({ email, displayName }: SettingsProfileProps) {
return (
<div className="rounded-xl border border-gray-100 bg-white p-6">
<h2 className="font-heading text-base font-semibold text-neutral-900">Profile</h2>
<p className="mt-1 text-sm text-neutral-500">Your public name and account email.</p>
<h2 className="font-heading text-base font-semibold text-neutral-900">{t("title")}</h2>
<p className="mt-1 text-sm text-neutral-500">{t("subtitle")}</p>
<div className="mt-6 flex items-center gap-4">
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-primary-100 font-heading text-xl font-bold text-primary-700">
@@ -56,25 +58,25 @@ export function SettingsProfile({ email, displayName }: SettingsProfileProps) {
<form onSubmit={(e) => void handleSave(e)} className="mt-6 space-y-4">
<div>
<label htmlFor="display-name" className="block text-sm font-medium text-neutral-700">
Display name
{t("displayNameLabel")}
</label>
<input
id="display-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Your name"
placeholder={t("displayNamePlaceholder")}
className="mt-1.5 block w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700">Email</label>
<label className="block text-sm font-medium text-neutral-700">{t("emailLabel")}</label>
<div className="mt-1.5 flex items-center gap-2 rounded-lg border border-gray-200 bg-neutral-50 px-3 py-2">
<User className="h-4 w-4 text-neutral-400" aria-hidden />
<span className="text-sm text-neutral-500">{email}</span>
</div>
<p className="mt-1 text-xs text-neutral-400">Email cannot be changed here. Contact support.</p>
<p className="mt-1 text-xs text-neutral-400">{t("emailHint")}</p>
</div>
{message && (
@@ -88,7 +90,7 @@ export function SettingsProfile({ email, displayName }: SettingsProfileProps) {
disabled={saving}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-primary-700 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
>
{saving ? "Saving" : "Save changes"}
{saving ? t("saving") : t("saveChanges")}
</button>
</form>
</div>
@@ -1,9 +1,11 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Eye, EyeOff } from "lucide-react";
export function SettingsSecurity() {
const t = useTranslations("auto.componentsDashboardSettingsSettingsSecurity");
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [confirm, setConfirm] = useState("");
@@ -16,11 +18,11 @@ export function SettingsSecurity() {
setMessage(null);
if (next.length < 8) {
setMessage({ type: "error", text: "New password must be at least 8 characters." });
setMessage({ type: "error", text: t("errorMinLength") });
return;
}
if (next !== confirm) {
setMessage({ type: "error", text: "Passwords do not match." });
setMessage({ type: "error", text: t("errorMismatch") });
return;
}
@@ -35,13 +37,13 @@ export function SettingsSecurity() {
});
const data = (await res.json().catch(() => null)) as { error?: string } | null;
if (!res.ok) {
setMessage({ type: "error", text: data?.error ?? "Could not change password." });
setMessage({ type: "error", text: data?.error ?? t("errorChangeFailed") });
} else {
setMessage({ type: "success", text: "Password changed successfully." });
setMessage({ type: "success", text: t("changeSuccess") });
setCurrent(""); setNext(""); setConfirm("");
}
} catch {
setMessage({ type: "error", text: "Network error. Please try again." });
setMessage({ type: "error", text: t("networkError") });
} finally {
setSaving(false);
}
@@ -64,7 +66,7 @@ export function SettingsSecurity() {
type="button"
onClick={() => setShowPw((v) => !v)}
className="absolute inset-y-0 right-2 flex items-center text-neutral-400 hover:text-neutral-600"
aria-label={showPw ? "Hide password" : "Show password"}
aria-label={showPw ? t("hidePassword") : t("showPassword")}
>
{showPw ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
@@ -75,13 +77,13 @@ export function SettingsSecurity() {
return (
<div className="rounded-xl border border-gray-100 bg-white p-6">
<h2 className="font-heading text-base font-semibold text-neutral-900">Security</h2>
<p className="mt-1 text-sm text-neutral-500">Change your account password.</p>
<h2 className="font-heading text-base font-semibold text-neutral-900">{t("title")}</h2>
<p className="mt-1 text-sm text-neutral-500">{t("subtitle")}</p>
<form onSubmit={(e) => void handleSubmit(e)} className="mt-6 space-y-4">
<PwInput id="current-pw" label="Current password" value={current} onChange={setCurrent} />
<PwInput id="new-pw" label="New password" value={next} onChange={setNext} />
<PwInput id="confirm-pw" label="Confirm new password" value={confirm} onChange={setConfirm} />
<PwInput id="current-pw" label={t("currentPasswordLabel")} value={current} onChange={setCurrent} />
<PwInput id="new-pw" label={t("newPasswordLabel")} value={next} onChange={setNext} />
<PwInput id="confirm-pw" label={t("confirmPasswordLabel")} value={confirm} onChange={setConfirm} />
{message && (
<p className={`text-sm ${message.type === "success" ? "text-green-600" : "text-red-600"}`}>
@@ -94,7 +96,7 @@ export function SettingsSecurity() {
disabled={saving}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-primary-700 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
>
{saving ? "Saving" : "Change password"}
{saving ? t("saving") : t("changePassword")}
</button>
</form>
</div>
+10 -11
View File
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Loader2, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -19,6 +20,7 @@ import {
} from "@/lib/image-editor-store";
export function AiRemoveBgModal() {
const t = useTranslations("auto.componentsImageEditorAiRemoveBgModal");
const isOpen = useImageEditorStore((s) => s.isAiModalOpen);
const setAiModalOpen = useImageEditorStore((s) => s.setAiModalOpen);
const replaceBaseImage = useImageEditorStore((s) => s.replaceBaseImage);
@@ -29,7 +31,7 @@ export function AiRemoveBgModal() {
const stage = getImageEditorStage();
const base = getBaseImageLayer({ layers });
if (!stage || !base) {
toast({ title: "Open an image first." });
toast({ title: t("openImageFirst") });
return;
}
@@ -48,15 +50,15 @@ export function AiRemoveBgModal() {
};
if (!response.ok || !payload.image) {
toast({ title: payload.error ?? "Background removal failed." });
toast({ title: payload.error ?? t("removalFailed") });
return;
}
replaceBaseImage(payload.image);
toast({ title: "Background removed!" });
toast({ title: t("backgroundRemoved") });
setAiModalOpen(false);
} catch {
toast({ title: "Could not reach background removal service." });
toast({ title: t("serviceUnreachable") });
} finally {
setIsLoading(false);
}
@@ -68,12 +70,9 @@ export function AiRemoveBgModal() {
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary-400" />
AI Background Removal
{t("title")}
</DialogTitle>
<DialogDescription>
Remove the background from your base image. The result replaces the
background layer with a transparent PNG.
</DialogDescription>
<DialogDescription>{t("description")}</DialogDescription>
</DialogHeader>
<Button
type="button"
@@ -84,10 +83,10 @@ export function AiRemoveBgModal() {
{isLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Processing
{t("processing")}
</>
) : (
"Remove Background"
t("removeBackground")
)}
</Button>
</DialogContent>
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import type { ImageCropAspectRatio } from "@/lib/image-editor-types";
@@ -16,6 +17,7 @@ const ASPECT_OPTIONS: { id: ImageCropAspectRatio; label: string }[] = [
];
export function ImageCropControls() {
const t = useTranslations("auto.componentsImageEditorImageCropControls");
const [applying, setApplying] = useState(false);
const activeTool = useImageEditorStore((s) => s.activeTool);
const cropAspectRatio = useImageEditorStore((s) => s.cropAspectRatio);
@@ -49,7 +51,7 @@ export function ImageCropControls() {
: "border-gray-700 bg-gray-800 text-gray-200 hover:border-gray-600"
)}
>
{option.label}
{option.id === "free" ? t("aspectFree") : option.label}
</button>
))}
</div>
@@ -62,7 +64,7 @@ export function ImageCropControls() {
onClick={cancelCrop}
disabled={applying}
>
Cancel
{t("cancel")}
</Button>
<Button
type="button"
@@ -71,7 +73,7 @@ export function ImageCropControls() {
onClick={() => void handleApply()}
disabled={applying}
>
{applying ? "Applying" : "Apply Crop"}
{applying ? t("applying") : t("applyCrop")}
</Button>
</div>
</div>
@@ -1,5 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
import { AdjustPanel } from "@/components/image-editor/panels/AdjustPanel";
import { FiltersPanel } from "@/components/image-editor/panels/FiltersPanel";
import { LayersPanel } from "@/components/image-editor/panels/LayersPanel";
@@ -7,20 +9,21 @@ import type { ImagePanelTab } from "@/lib/image-editor-types";
import { useImageEditorStore } from "@/lib/image-editor-store";
import { cn } from "@/lib/utils";
const TABS: { id: ImagePanelTab; label: string }[] = [
{ id: "adjust", label: "Adjust" },
{ id: "filters", label: "Filters" },
{ id: "layers", label: "Layers" },
const TAB_IDS: { id: ImagePanelTab; labelKey: string }[] = [
{ id: "adjust", labelKey: "tabAdjust" },
{ id: "filters", labelKey: "tabFilters" },
{ id: "layers", labelKey: "tabLayers" },
];
export function ImageEditorRightPanel() {
const t = useTranslations("auto.componentsImageEditorImageEditorRightPanel");
const activePanelTab = useImageEditorStore((s) => s.activePanelTab);
const setActivePanelTab = useImageEditorStore((s) => s.setActivePanelTab);
return (
<aside className="flex w-[280px] shrink-0 flex-col border-l border-gray-800 bg-gray-900">
<div className="flex border-b border-gray-800">
{TABS.map((tab) => (
{TAB_IDS.map((tab) => (
<button
key={tab.id}
type="button"
@@ -32,7 +35,7 @@ export function ImageEditorRightPanel() {
: "text-gray-500 hover:text-gray-300"
)}
>
{tab.label}
{t(tab.labelKey)}
</button>
))}
</div>
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import {
Crop,
MousePointer2,
@@ -19,23 +20,25 @@ import type { ImageShapeKind, ImageTool } from "@/lib/image-editor-types";
import { useImageEditorStore } from "@/lib/image-editor-store";
import { cn } from "@/lib/utils";
const TOOLS: { id: ImageTool; label: string; icon: typeof MousePointer2 }[] = [
{ id: "select", label: "Select", icon: MousePointer2 },
{ id: "crop", label: "Crop", icon: Crop },
{ id: "text", label: "Text", icon: Type },
{ id: "shape", label: "Shape", icon: Shapes },
{ id: "draw", label: "Draw", icon: Pencil },
{ id: "ai", label: "AI", icon: Sparkles },
];
const TOOLS: { id: ImageTool; labelKey: string; icon: typeof MousePointer2 }[] =
[
{ id: "select", labelKey: "toolSelect", icon: MousePointer2 },
{ id: "crop", labelKey: "toolCrop", icon: Crop },
{ id: "text", labelKey: "toolText", icon: Type },
{ id: "shape", labelKey: "toolShape", icon: Shapes },
{ id: "draw", labelKey: "toolDraw", icon: Pencil },
{ id: "ai", labelKey: "toolAi", icon: Sparkles },
];
const SHAPES: { id: ImageShapeKind; label: string }[] = [
{ id: "rect", label: "Rectangle" },
{ id: "circle", label: "Circle" },
{ id: "line", label: "Line" },
{ id: "arrow", label: "Arrow" },
const SHAPES: { id: ImageShapeKind; labelKey: string }[] = [
{ id: "rect", labelKey: "shapeRectangle" },
{ id: "circle", labelKey: "shapeCircle" },
{ id: "line", labelKey: "shapeLine" },
{ id: "arrow", labelKey: "shapeArrow" },
];
export function ImageEditorToolbar() {
const t = useTranslations("auto.componentsImageEditorImageEditorToolbar");
const [shapeOpen, setShapeOpen] = useState(false);
const activeTool = useImageEditorStore((s) => s.activeTool);
const setActiveTool = useImageEditorStore((s) => s.setActiveTool);
@@ -52,7 +55,7 @@ export function ImageEditorToolbar() {
<PopoverTrigger asChild>
<button
type="button"
title={tool.label}
title={t(tool.labelKey)}
onClick={() => setActiveTool("shape")}
className={cn(
"flex h-10 w-10 items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500",
@@ -76,7 +79,7 @@ export function ImageEditorToolbar() {
}}
className="flex w-full rounded-md px-2 py-2 text-left text-sm text-gray-200 hover:bg-gray-700"
>
{shape.label}
{t(shape.labelKey)}
</button>
))}
</PopoverContent>
@@ -88,7 +91,7 @@ export function ImageEditorToolbar() {
<button
key={tool.id}
type="button"
title={tool.label}
title={t(tool.labelKey)}
onClick={() => {
if (tool.id === "ai") {
setAiModalOpen(true);
@@ -1,6 +1,7 @@
"use client";
import { useRef, useState } from "react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { Download, FolderOpen, Sparkles } from "lucide-react";
@@ -28,6 +29,7 @@ export function ImageEditorTopBar({
saveStatus = "idle",
onSaveRetry,
}: ImageEditorTopBarProps) {
const t = useTranslations("auto.componentsImageEditorImageEditorTopBar");
const fileRef = useRef<HTMLInputElement>(null);
const [exportOpen, setExportOpen] = useState(false);
@@ -52,11 +54,11 @@ export function ImageEditorTopBar({
const handleExport = () => {
const stage = getImageEditorStage();
if (!stage) {
toast({ title: "Canvas not ready." });
toast({ title: t("canvasNotReady") });
return;
}
downloadStageImage(stage, exportFormat, exportQuality);
toast({ title: "Export started" });
toast({ title: t("exportStarted") });
setExportOpen(false);
};
@@ -69,7 +71,7 @@ export function ImageEditorTopBar({
>
<Sparkles className="h-4 w-4 text-violet-500" />
<span className="font-heading font-semibold text-white">
{projectName ?? "Image Editor"}
{projectName ?? t("defaultProjectName")}
</span>
</Link>
{projectId ? (
@@ -95,7 +97,7 @@ export function ImageEditorTopBar({
onClick={() => fileRef.current?.click()}
>
<FolderOpen className="h-4 w-4" />
Open
{t("open")}
</Button>
</div>
@@ -108,11 +110,13 @@ export function ImageEditorTopBar({
onClick={() => setExportOpen((v) => !v)}
>
<Download className="h-4 w-4" />
Export
{t("export")}
</Button>
{exportOpen ? (
<div className="absolute right-0 top-full z-50 mt-2 w-64 rounded-xl border border-gray-700 bg-gray-900 p-4 shadow-xl">
<p className="mb-2 text-xs font-semibold text-gray-400">Format</p>
<p className="mb-2 text-xs font-semibold text-gray-400">
{t("format")}
</p>
<div className="mb-4 flex gap-2">
{(["png", "jpg", "webp"] as ExportImageFormat[]).map((fmt) => (
<button
@@ -133,7 +137,7 @@ export function ImageEditorTopBar({
{exportFormat !== "png" ? (
<div className="mb-4">
<div className="mb-2 flex justify-between text-xs text-gray-400">
<span>Quality</span>
<span>{t("quality")}</span>
<span>{exportQuality}%</span>
</div>
<Slider
@@ -150,7 +154,7 @@ export function ImageEditorTopBar({
className="w-full bg-primary-600 hover:bg-primary-700"
onClick={handleExport}
>
Download
{t("download")}
</Button>
</div>
) : null}
@@ -1,35 +1,38 @@
"use client";
import { useTranslations } from "next-intl";
import { Slider } from "@/components/ui/slider";
import { useImageEditorStore } from "@/lib/image-editor-store";
const SLIDERS = [
{ key: "brightness" as const, label: "Brightness", min: -100, max: 100 },
{ key: "contrast" as const, label: "Contrast", min: -100, max: 100 },
{ key: "saturation" as const, label: "Saturation", min: -100, max: 100 },
{ key: "hue" as const, label: "Hue", min: -180, max: 180 },
{ key: "blur" as const, label: "Blur", min: 0, max: 20 },
{ key: "sharpen" as const, label: "Sharpen", min: 0, max: 10 },
{ key: "vignette" as const, label: "Vignette", min: 0, max: 100 },
{ key: "brightness" as const, labelKey: "brightness", min: -100, max: 100 },
{ key: "contrast" as const, labelKey: "contrast", min: -100, max: 100 },
{ key: "saturation" as const, labelKey: "saturation", min: -100, max: 100 },
{ key: "hue" as const, labelKey: "hue", min: -180, max: 180 },
{ key: "blur" as const, labelKey: "blur", min: 0, max: 20 },
{ key: "sharpen" as const, labelKey: "sharpen", min: 0, max: 10 },
{ key: "vignette" as const, labelKey: "vignette", min: 0, max: 100 },
];
export function AdjustPanel() {
const t = useTranslations("auto.componentsImageEditorPanelsAdjustPanel");
const adjustments = useImageEditorStore((s) => s.adjustments);
const setAdjustments = useImageEditorStore((s) => s.setAdjustments);
const hasBase = useImageEditorStore((s) => s.layers.some((l) => l.type === "image"));
if (!hasBase) {
return (
<p className="text-xs text-gray-500">Open an image to use adjustments.</p>
<p className="text-xs text-gray-500">{t("emptyState")}</p>
);
}
return (
<div className="space-y-5">
{SLIDERS.map(({ key, label, min, max }) => (
{SLIDERS.map(({ key, labelKey, min, max }) => (
<div key={key}>
<div className="mb-2 flex justify-between text-xs text-gray-400">
<span>{label}</span>
<span>{t(labelKey)}</span>
<span className="tabular-nums text-gray-300">
{adjustments[key]}
</span>
@@ -1,16 +1,19 @@
"use client";
import { useTranslations } from "next-intl";
import { FILTER_PRESETS } from "@/lib/image-editor-filters";
import { useImageEditorStore } from "@/lib/image-editor-store";
import { cn } from "@/lib/utils";
export function FiltersPanel() {
const t = useTranslations("auto.componentsImageEditorPanelsFiltersPanel");
const activeFilterPreset = useImageEditorStore((s) => s.activeFilterPreset);
const applyFilterPreset = useImageEditorStore((s) => s.applyFilterPreset);
const hasBase = useImageEditorStore((s) => s.layers.some((l) => l.type === "image"));
if (!hasBase) {
return <p className="text-xs text-gray-500">Open an image to apply filters.</p>;
return <p className="text-xs text-gray-500">{t("emptyState")}</p>;
}
return (
@@ -17,6 +17,7 @@ import {
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Eye, EyeOff, GripVertical, Trash2 } from "lucide-react";
import { useTranslations } from "next-intl";
import type { ImageLayer } from "@/lib/image-editor-types";
import { useImageEditorStore } from "@/lib/image-editor-store";
@@ -38,6 +39,7 @@ function layerIcon(type: ImageLayer["type"]): string {
}
function SortableLayerRow({ layer }: { layer: ImageLayer }) {
const t = useTranslations("auto.componentsImageEditorPanelsLayersPanel");
const selectedLayerId = useImageEditorStore((s) => s.selectedLayerId);
const setSelectedLayer = useImageEditorStore((s) => s.setSelectedLayer);
const toggleLayerVisibility = useImageEditorStore((s) => s.toggleLayerVisibility);
@@ -63,7 +65,7 @@ function SortableLayerRow({ layer }: { layer: ImageLayer }) {
<button
type="button"
className="cursor-grab text-gray-500 hover:text-gray-300"
aria-label={`Reorder ${layer.name}`}
aria-label={t("reorderLayer", { name: layer.name ?? "" })}
{...attributes}
{...listeners}
>
@@ -81,7 +83,7 @@ function SortableLayerRow({ layer }: { layer: ImageLayer }) {
type="button"
onClick={() => toggleLayerVisibility(layer.id)}
className="text-gray-400 hover:text-white"
aria-label={layer.visible ? "Hide layer" : "Show layer"}
aria-label={layer.visible ? t("hideLayer") : t("showLayer")}
>
{layer.visible ? (
<Eye className="h-3.5 w-3.5" />
@@ -94,7 +96,7 @@ function SortableLayerRow({ layer }: { layer: ImageLayer }) {
type="button"
onClick={() => deleteLayer(layer.id)}
className="text-gray-400 hover:text-red-400"
aria-label={`Delete ${layer.name}`}
aria-label={t("deleteLayer", { name: layer.name ?? "" })}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
@@ -106,6 +108,7 @@ function SortableLayerRow({ layer }: { layer: ImageLayer }) {
}
export function LayersPanel() {
const t = useTranslations("auto.componentsImageEditorPanelsLayersPanel");
const layers = useImageEditorStore((s) => s.layers);
const reorderLayers = useImageEditorStore((s) => s.reorderLayers);
@@ -133,7 +136,7 @@ export function LayersPanel() {
};
if (layers.length === 0) {
return <p className="text-xs text-gray-500">No layers yet.</p>;
return <p className="text-xs text-gray-500">{t("emptyState")}</p>;
}
return (
@@ -1,14 +1,20 @@
"use client";
import { useTranslations } from "next-intl";
import { OptimizedImage } from "@/components/ui/optimized-image";
import { placeholderSrc } from "@/lib/placeholder";
export function ImageMakerBeforeAfter() {
const t = useTranslations("auto.componentsImageMakerImageMakerBeforeAfter");
return (
<div className="overflow-hidden rounded-xl border border-gray-100 bg-white shadow-xl">
<div className="grid grid-cols-2 divide-x divide-gray-100">
<div className="relative">
<div className="relative aspect-[4/5] sm:aspect-square">
<OptimizedImage
src="https://picsum.photos/seed/im-before/400/500"
alt="Before editing"
src={placeholderSrc("im-before", 400, 500)}
alt={t("beforeAlt")}
fill
priority
sizes="(max-width: 1024px) 50vw, 320px"
@@ -16,14 +22,14 @@ export function ImageMakerBeforeAfter() {
/>
</div>
<span className="absolute left-3 top-3 rounded-md bg-neutral-900/70 px-2 py-1 text-xs font-semibold text-white backdrop-blur-sm">
Before
{t("beforeLabel")}
</span>
</div>
<div className="relative">
<div className="relative aspect-[4/5] sm:aspect-square">
<OptimizedImage
src="https://picsum.photos/seed/im-after/400/500"
alt="After editing with AI"
src={placeholderSrc("im-after", 400, 500)}
alt={t("afterAlt")}
fill
priority
sizes="(max-width: 1024px) 50vw, 320px"
@@ -31,12 +37,12 @@ export function ImageMakerBeforeAfter() {
/>
</div>
<span className="absolute left-3 top-3 rounded-md bg-violet-600 px-2 py-1 text-xs font-semibold text-white">
After
{t("afterLabel")}
</span>
</div>
</div>
<p className="border-t border-gray-100 bg-neutral-50 px-4 py-3 text-center text-xs text-neutral-500">
AI-enhanced color, layout, and brand styling applied in one click
{t("caption")}
</p>
</div>
);
@@ -1,19 +1,23 @@
import { getTranslations } from "next-intl/server";
import { OptimizedImage } from "@/components/ui/optimized-image";
import { SectionReveal } from "@/components/sections/SectionReveal";
import { placeholderSrc } from "@/lib/placeholder";
import { GALLERY_ITEMS } from "./image-maker-gallery-data";
export function ImageMakerGallery() {
export async function ImageMakerGallery() {
const t = await getTranslations("auto.componentsImageMakerImageMakerGallery");
return (
<section id="gallery" className="bg-neutral-50 py-20 sm:py-28">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<SectionReveal>
<h2 className="text-center font-heading text-3xl font-bold text-neutral-900 sm:text-4xl">
Example outputs from creators
{t("title")}
</h2>
<p className="mx-auto mt-4 max-w-2xl text-center text-neutral-600">
Real-world layouts and styles you can recreateor use as inspiration
for your next project.
{t("subtitle")}
</p>
</SectionReveal>
@@ -25,7 +29,7 @@ export function ImageMakerGallery() {
>
<div className={`relative w-full ${item.aspectClass}`}>
<OptimizedImage
src={`https://picsum.photos/seed/${item.id}/600/800`}
src={placeholderSrc(item.id, 600, 800)}
alt={item.alt}
fill
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
@@ -0,0 +1,19 @@
"use client";
import { DirectionProvider as RadixDirectionProvider } from "@radix-ui/react-direction";
/**
* Propagates the document direction to all Radix UI primitives (dropdown menus,
* selects, popovers, sheets…). Radix portals its content out of the DOM tree and
* assumes LTR unless told otherwise — without this, RTL menus render left-aligned
* with `align="start"` resolving to the wrong side.
*/
export function DirectionProvider({
dir,
children,
}: {
dir: "rtl" | "ltr";
children: React.ReactNode;
}) {
return <RadixDirectionProvider dir={dir}>{children}</RadixDirectionProvider>;
}
+6 -2
View File
@@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { ChevronDown, LayoutGrid } from "lucide-react";
import {
@@ -68,11 +69,14 @@ interface NavbarLearnDropdownProps {
label?: string;
}
export function NavbarLearnDropdown({ items, label = "Learn" }: NavbarLearnDropdownProps) {
export function NavbarLearnDropdown({ items, label }: NavbarLearnDropdownProps) {
const t = useTranslations("auto.componentsLayoutNavbarMenuDropdown");
const resolvedLabel = label ?? t("learn");
return (
<DropdownMenu>
<DropdownMenuTrigger className={triggerClassName}>
{label}
{resolvedLabel}
<ChevronDown className="h-3.5 w-3.5 text-gray-500" aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={cn(panelClassName, "min-w-[180px]")}>
+7 -4
View File
@@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { LayoutGrid } from "lucide-react";
import {
@@ -17,11 +18,13 @@ const linkClass =
"flex min-h-11 items-center rounded-lg px-3 text-sm font-medium text-gray-700 hover:bg-gray-50 hover:text-gray-900";
export function NavbarMobileMenu({ onNavigate }: NavbarMobileMenuProps) {
const t = useTranslations("auto.componentsLayoutNavbarMobileMenu");
return (
<div className="flex flex-1 flex-col gap-6 overflow-y-auto">
<section>
<p className="px-3 text-xs font-semibold uppercase tracking-wide text-gray-400">
Video Maker
{t("videoMaker")}
</p>
<Link
href={VIDEO_MAKER_NAV.browseHref}
@@ -44,7 +47,7 @@ export function NavbarMobileMenu({ onNavigate }: NavbarMobileMenuProps) {
<section>
<p className="px-3 text-xs font-semibold uppercase tracking-wide text-gray-400">
Image Maker
{t("imageMaker")}
</p>
<Link
href={IMAGE_MAKER_NAV.browseHref}
@@ -67,13 +70,13 @@ export function NavbarMobileMenu({ onNavigate }: NavbarMobileMenuProps) {
<section>
<Link href="/pricing" onClick={onNavigate} className={linkClass}>
Pricing
{t("pricing")}
</Link>
</section>
<section>
<p className="px-3 text-xs font-semibold uppercase tracking-wide text-gray-400">
Learn
{t("learn")}
</p>
<ul className="mt-1 space-y-0.5">
{LEARN_NAV_ITEMS.map((item) => (
+33 -25
View File
@@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { motion, type Variants } from "framer-motion";
import { VideoPlayOverlay } from "@/components/sections/VideoPlayOverlay";
@@ -8,10 +9,10 @@ import { getHeroPreviewVideoSrc } from "@/lib/template-preview-media";
import { cn } from "@/lib/utils";
const previewTemplates = [
{ id: "hero-3d", title: "Factory of 3D Animations" },
{ id: "hero-whiteboard", title: "Whiteboard Animation Toolkit" },
{ id: "hero-explainer", title: "3D Explainer Video Toolkit" },
{ id: "hero-trendy", title: "Trendy Explainer Toolkit" },
{ id: "hero-3d", titleKey: "template3dTitle" },
{ id: "hero-whiteboard", titleKey: "templateWhiteboardTitle" },
{ id: "hero-explainer", titleKey: "templateExplainerTitle" },
{ id: "hero-trendy", titleKey: "templateTrendyTitle" },
] as const;
const containerVariants: Variants = {
@@ -37,6 +38,8 @@ interface HeroVideoThumbProps {
}
function HeroVideoThumb({ videoSrc, label }: HeroVideoThumbProps) {
const t = useTranslations("auto.componentsSectionsHeroPreviewCards");
return (
<div className="group/thumb relative aspect-[4/3] overflow-hidden rounded-xl border border-neutral-200/80 bg-neutral-100 shadow-sm transition-shadow duration-300 hover:shadow-md">
<video
@@ -47,7 +50,7 @@ function HeroVideoThumb({ videoSrc, label }: HeroVideoThumbProps) {
playsInline
preload="metadata"
className="h-full w-full object-cover transition-transform duration-500 ease-out group-hover/thumb:scale-[1.02]"
aria-label={`${label} preview`}
aria-label={t("previewAriaLabel", { label })}
/>
<VideoPlayOverlay
size="lg"
@@ -58,6 +61,8 @@ function HeroVideoThumb({ videoSrc, label }: HeroVideoThumbProps) {
}
export function HeroPreviewCards() {
const t = useTranslations("auto.componentsSectionsHeroPreviewCards");
return (
<motion.div
variants={containerVariants}
@@ -67,29 +72,32 @@ export function HeroPreviewCards() {
className="mx-auto mt-14 w-full max-w-7xl sm:mt-16"
>
<p className="text-center font-heading text-xl font-bold tracking-tight text-neutral-900 sm:text-2xl">
Made by world-class motion designers
{t("heading")}
</p>
<div className="mt-8 grid grid-cols-2 gap-4 sm:gap-5 lg:grid-cols-4 lg:gap-6">
{previewTemplates.map((template, index) => (
<motion.div key={template.id} variants={cardVariants}>
<Link
href="/templates"
className={cn(
"group block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rf-blue focus-visible:ring-offset-2",
"transition-transform duration-300 hover:-translate-y-0.5"
)}
>
<HeroVideoThumb
videoSrc={getHeroPreviewVideoSrc(index)}
label={template.title}
/>
<p className="mt-3 text-center font-heading text-sm font-semibold text-neutral-900 sm:text-[15px]">
{template.title}
</p>
</Link>
</motion.div>
))}
{previewTemplates.map((template, index) => {
const title = t(template.titleKey);
return (
<motion.div key={template.id} variants={cardVariants}>
<Link
href="/templates"
className={cn(
"group block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rf-blue focus-visible:ring-offset-2",
"transition-transform duration-300 hover:-translate-y-0.5"
)}
>
<HeroVideoThumb
videoSrc={getHeroPreviewVideoSrc(index)}
label={title}
/>
<p className="mt-3 text-center font-heading text-sm font-semibold text-neutral-900 sm:text-[15px]">
{title}
</p>
</Link>
</motion.div>
);
})}
</div>
</motion.div>
);
@@ -1,6 +1,7 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
@@ -22,6 +23,7 @@ export function PricingAnimatedPrice({
size = "default",
}: PricingAnimatedPriceProps) {
const isCompact = size === "compact";
const t = useTranslations("auto.componentsSectionsPricingAnimatedPrice");
return (
<div className={isCompact ? "mt-2" : "mt-4"}>
@@ -53,7 +55,7 @@ export function PricingAnimatedPrice({
${formatPrice(price)}
</motion.span>
</AnimatePresence>
<span className="ml-1 text-sm font-normal text-neutral-500">/ month</span>
<span className="ml-1 text-sm font-normal text-neutral-500">{t("perMonth")}</span>
</div>
</div>
);
@@ -1,6 +1,7 @@
"use client";
import { motion } from "framer-motion";
import { useTranslations } from "next-intl";
import type { BillingPeriod } from "@/components/sections/pricing-data";
import { ANNUAL_SAVINGS_PERCENT } from "@/components/sections/pricing-data";
@@ -17,12 +18,13 @@ export function PricingBillingToggle({
onChange,
layoutId = "pricing-billing-pill",
}: PricingBillingToggleProps) {
const t = useTranslations("auto.componentsSectionsPricingBillingToggle");
return (
<div className="flex flex-col items-center gap-2">
<div className="inline-flex rounded-full border border-gray-200 bg-white p-1 shadow-sm">
{(["monthly", "annual"] as const).map((period) => {
const isActive = billing === period;
const label = period === "monthly" ? "Monthly" : "Yearly";
const label = period === "monthly" ? t("monthly") : t("yearly");
return (
<button
key={period}
@@ -47,10 +49,10 @@ export function PricingBillingToggle({
</div>
{billing === "annual" ? (
<span className="rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-semibold text-green-700">
Save {ANNUAL_SAVINGS_PERCENT}%
{t("savePercent", { percent: ANNUAL_SAVINGS_PERCENT })}
</span>
) : (
<p className="text-sm text-neutral-400">Switch to Yearly to save more</p>
<p className="text-sm text-neutral-400">{t("switchToYearly")}</p>
)}
</div>
);
+3 -1
View File
@@ -2,6 +2,7 @@
import Link from "next/link";
import { Tag } from "lucide-react";
import { useTranslations } from "next-intl";
import { PricingAnimatedPrice } from "@/components/sections/PricingAnimatedPrice";
import { PricingCheckoutButton } from "@/components/sections/PricingCheckoutButton";
@@ -22,6 +23,7 @@ export interface PricingCardProps {
}
export function PricingCard({ tier, billing }: PricingCardProps) {
const t = useTranslations("auto.componentsSectionsPricingCard");
const price = getDisplayPrice(tier, billing);
const compareAt = getCompareAtPrice(tier, billing);
const highlighted = tier.highlighted ?? false;
@@ -38,7 +40,7 @@ export function PricingCard({ tier, billing }: PricingCardProps) {
>
{highlighted ? (
<div className="bg-gradient-to-r from-rose-400 via-violet-500 to-violet-600 px-4 py-2 text-center text-sm font-semibold text-white">
Most Popular
{t("mostPopular")}
</div>
) : null}
@@ -2,6 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Loader2 } from "lucide-react";
import type { BillingPeriod } from "@/components/sections/pricing-data";
@@ -25,6 +26,7 @@ export function PricingCheckoutButton({
variant = "default",
}: PricingCheckoutButtonProps) {
const router = useRouter();
const t = useTranslations("auto.componentsSectionsPricingCheckoutButton");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -46,7 +48,7 @@ export function PricingCheckoutButton({
router.push(`/auth?tab=sign-up&plan=${plan}`);
return;
}
throw new Error(data.error ?? "Checkout failed.");
throw new Error(data.error ?? t("checkoutFailed"));
}
if (data.url) {
@@ -54,12 +56,12 @@ export function PricingCheckoutButton({
return;
}
throw new Error("No checkout URL returned.");
throw new Error(t("noCheckoutUrl"));
} catch (checkoutError) {
setError(
checkoutError instanceof Error
? checkoutError.message
: "Checkout failed."
: t("checkoutFailed")
);
} finally {
setLoading(false);
@@ -2,6 +2,7 @@
import { Fragment } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { PricingAnimatedPrice } from "@/components/sections/PricingAnimatedPrice";
import { PricingBillingToggle } from "@/components/sections/PricingBillingToggle";
@@ -62,6 +63,7 @@ function PlanHeaderCell({
tier: PricingTier;
billing: BillingPeriod;
}) {
const t = useTranslations("auto.componentsSectionsPricingCompareTable");
const highlighted = tier.highlighted ?? false;
const isStripePlan = tier.id === "pro" || tier.id === "business";
@@ -74,7 +76,7 @@ function PlanHeaderCell({
>
{highlighted ? (
<span className="mb-2 inline-block rounded-full bg-gradient-to-r from-violet-500 to-blue-600 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-white">
Most Popular
{t("mostPopular")}
</span>
) : (
<span className="mb-2 block h-5" aria-hidden />
@@ -118,6 +120,7 @@ export function PricingCompareTable({
billing,
onBillingChange,
}: PricingCompareTableProps) {
const t = useTranslations("auto.componentsSectionsPricingCompareTable");
const lite = PRICING_TIERS.find((t) => t.id === "lite");
const pro = PRICING_TIERS.find((t) => t.id === "pro");
const business = PRICING_TIERS.find((t) => t.id === "business");
@@ -131,7 +134,7 @@ export function PricingCompareTable({
<tr className="border-b border-gray-100">
<th className="w-[38%] px-6 pb-4 pt-6 text-left align-top">
<h3 className="bg-gradient-to-r from-blue-600 to-violet-600 bg-clip-text font-heading text-lg font-bold text-transparent sm:text-xl">
Compare Plans &amp; Features
{t("compareHeading")}
</h3>
<div className="mt-4 items-start">
<PricingBillingToggle
@@ -141,7 +144,7 @@ export function PricingCompareTable({
/>
</div>
<p className="mt-3 flex items-center gap-1 text-xs font-bold uppercase tracking-wide text-blue-600">
Save up to {COMPARE_ANNUAL_SAVINGS_BADGE}%
{t("saveUpTo", { percent: COMPARE_ANNUAL_SAVINGS_BADGE })}
<SavingsArrowIcon />
</p>
</th>
@@ -1,11 +1,16 @@
"use client";
import { Zap } from "lucide-react";
import { useTranslations } from "next-intl";
export function PricingCreditsBanner() {
const t = useTranslations("auto.componentsSectionsPricingCreditsBanner");
return (
<div className="flex items-start gap-2 rounded-lg bg-sky-50 px-3 py-2.5 text-left">
<Zap className="mt-0.5 h-4 w-4 shrink-0 text-rf-blue" aria-hidden />
<p className="text-xs leading-snug text-neutral-700">
You can refill AI credits anytime with an active plan
{t("refillCredits")}
</p>
</div>
);
@@ -1,4 +1,7 @@
"use client";
import { Check, Info } from "lucide-react";
import { useTranslations } from "next-intl";
import type { PricingFeature } from "@/components/sections/pricing-data";
@@ -11,6 +14,8 @@ export function PricingFeatureList({
heading,
features,
}: PricingFeatureListProps) {
const t = useTranslations("auto.componentsSectionsPricingFeatureList");
return (
<div className="mt-6">
{heading ? (
@@ -36,7 +41,7 @@ export function PricingFeatureList({
{feature.info ? (
<Info
className="h-3.5 w-3.5 shrink-0 text-neutral-400"
aria-label="More information"
aria-label={t("moreInformation")}
/>
) : null}
</span>
@@ -1,17 +1,21 @@
"use client";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
export function PricingFreeBanner() {
const t = useTranslations("auto.componentsSectionsPricingFreeBanner");
return (
<div className="flex flex-col items-start justify-between gap-6 rounded-xl border border-gray-100 bg-white px-6 py-6 shadow-sm sm:flex-row sm:items-center sm:px-8">
<div className="max-w-xl">
<h3 className="font-heading text-lg font-bold text-neutral-900 sm:text-xl">
Always Free to Try
{t("title")}
</h3>
<p className="mt-2 text-sm leading-relaxed text-neutral-600 sm:text-[15px]">
Explore CreatorStudio with a Free plan create HD videos with a
watermark, try basic features, and experiment before you subscribe.
{t("description")}
</p>
</div>
<Button
@@ -20,7 +24,7 @@ export function PricingFreeBanner() {
className="shrink-0 rounded-lg border-2 border-rf-blue bg-white px-8 text-rf-blue hover:bg-rf-blue-light"
asChild
>
<Link href="/auth?tab=sign-up">Get Started</Link>
<Link href="/auth?tab=sign-up">{t("ctaLabel")}</Link>
</Button>
</div>
);
+8 -4
View File
@@ -3,6 +3,7 @@
import { useCallback, useState } from "react";
import Link from "next/link";
import { AnimatePresence, motion } from "framer-motion";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { OptimizedImage } from "@/components/ui/optimized-image";
@@ -40,9 +41,12 @@ export function TemplateCard({
priority = false,
onUseTemplate,
isUsingTemplate = false,
useTemplateLabel = "Use Template",
openingLabel = "Opening…",
useTemplateLabel,
openingLabel,
}: TemplateCardProps) {
const t = useTranslations("auto.componentsSectionsTemplateCard");
const resolvedUseTemplateLabel = useTemplateLabel ?? t("useTemplateLabel");
const resolvedOpeningLabel = openingLabel ?? t("openingLabel");
const [isHovered, setIsHovered] = useState(false);
const seed = previewSeed ?? name;
const videoSrc = previewVideoUrl ?? getTemplatePreviewVideoSrc(seed);
@@ -64,7 +68,7 @@ export function TemplateCard({
<Link
href={detailHref}
className="absolute inset-0 z-0 block no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2"
aria-label={`View ${name} template`}
aria-label={t("viewTemplateAriaLabel", { name })}
>
<OptimizedImage
src={imageSrc}
@@ -120,7 +124,7 @@ export function TemplateCard({
onUseTemplate?.();
}}
>
{isUsingTemplate ? openingLabel : useTemplateLabel}
{isUsingTemplate ? resolvedOpeningLabel : resolvedUseTemplateLabel}
</Button>
</motion.div>
) : null}
+5 -1
View File
@@ -1,4 +1,7 @@
"use client";
import { Star } from "lucide-react";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
@@ -13,6 +16,7 @@ export function TestimonialCard({
testimonial,
className,
}: TestimonialCardProps) {
const t = useTranslations("auto.componentsSectionsTestimonialCard");
const { name, role, company, quote, initials } = testimonial;
return (
@@ -22,7 +26,7 @@ export function TestimonialCard({
className
)}
>
<p className="sr-only">Rated 5 out of 5 stars</p>
<p className="sr-only">{t("ratingLabel")}</p>
<div className="flex items-center gap-1" aria-hidden>
{Array.from({ length: 5 }).map((_, index) => (
<Star
@@ -1,3 +1,5 @@
import { placeholderSrc } from "@/lib/placeholder";
export const FILTER_TABS = [
"All",
"Videos",
@@ -78,7 +80,7 @@ export function filterTemplates(
}
export function getTemplateImageSrc(id: string): string {
return `https://picsum.photos/seed/${id}/400/300`;
return placeholderSrc(id, 400, 300);
}
/** Video presets for /studio/video/new onboarding */
@@ -94,49 +96,49 @@ export const TEMPLATE_GALLERY_ITEMS: TemplateGalleryItem[] = [
{
id: "promo-reel",
name: "Animated Inspirational Video",
imageSrc: "https://picsum.photos/seed/promo-reel/400/280",
imageSrc: placeholderSrc("promo-reel", 400, 280),
previewVideoUrl: MIXKIT.sunsetPlateaus,
},
{
id: "product-launch",
name: "Cybersecurity Company Promo",
imageSrc: "https://picsum.photos/seed/product-launch/400/280",
imageSrc: placeholderSrc("product-launch", 400, 280),
previewVideoUrl: MIXKIT.cityTraffic,
},
{
id: "brand-story",
name: "Get to Know Your Customers Day",
imageSrc: "https://picsum.photos/seed/brand-story/400/280",
imageSrc: placeholderSrc("brand-story", 400, 280),
previewVideoUrl: MIXKIT.cloudsRunner,
},
{
id: "instagram-carousel",
name: "SEO Agency Introduction",
imageSrc: "https://picsum.photos/seed/instagram/400/280",
imageSrc: placeholderSrc("instagram", 400, 280),
previewVideoUrl: MIXKIT.meadow,
},
{
id: "tiktok-hook",
name: "Tech Startup Promo",
imageSrc: "https://picsum.photos/seed/tiktok/400/280",
imageSrc: placeholderSrc("tiktok", 400, 280),
previewVideoUrl: MIXKIT.skyscrapers,
},
{
id: "pitch-deck",
name: "Corporate Explainer",
imageSrc: "https://picsum.photos/seed/pitch-deck/400/280",
imageSrc: placeholderSrc("pitch-deck", 400, 280),
previewVideoUrl: MIXKIT.cityTraffic,
},
{
id: "hero-promo",
name: "Hero Product Launch",
imageSrc: "https://picsum.photos/seed/hero-promo/400/280",
imageSrc: placeholderSrc("hero-promo", 400, 280),
previewVideoUrl: MIXKIT.sunsetPlateaus,
},
{
id: "event-recap",
name: "Event Recap Highlight",
imageSrc: "https://picsum.photos/seed/event-recap/400/280",
imageSrc: placeholderSrc("event-recap", 400, 280),
previewVideoUrl: MIXKIT.meadow,
},
];
+7 -5
View File
@@ -2,6 +2,7 @@
import { useState } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { FilePlus, LayoutTemplate, Plus } from "lucide-react";
import {
@@ -16,6 +17,7 @@ interface AddSceneMenuProps {
}
export function AddSceneMenu({ onAddBlank, variant = "footer" }: AddSceneMenuProps) {
const t = useTranslations("auto.componentsStudioAddSceneMenu");
const [open, setOpen] = useState(false);
const isHeader = variant === "header";
@@ -26,8 +28,8 @@ export function AddSceneMenu({ onAddBlank, variant = "footer" }: AddSceneMenuPro
<button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-md text-[#8b91a7] transition-colors hover:bg-[#1f2234] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]"
aria-label="Add scene"
title="Add scene"
aria-label={t("addScene")}
title={t("addScene")}
>
<Plus className="h-4 w-4" />
</button>
@@ -37,7 +39,7 @@ export function AddSceneMenu({ onAddBlank, variant = "footer" }: AddSceneMenuPro
className="flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-[#2a2d3e] bg-[#1a1d2e]/50 px-3 py-2 text-xs font-medium text-gray-300 transition-colors hover:border-[#3d4260] hover:bg-[#1f2234] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]"
>
<Plus className="h-3.5 w-3.5" aria-hidden />
Add Scene
{t("addScene")}
</button>
)}
</PopoverTrigger>
@@ -51,14 +53,14 @@ export function AddSceneMenu({ onAddBlank, variant = "footer" }: AddSceneMenuPro
className="flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm text-gray-200 hover:bg-[#252938] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]"
>
<FilePlus className="h-4 w-4 shrink-0" aria-hidden />
Blank Scene
{t("blankScene")}
</button>
<Link
href="/templates"
className="flex w-full items-center gap-2 rounded-md px-2 py-2 text-sm text-gray-200 hover:bg-[#252938] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]"
>
<LayoutTemplate className="h-4 w-4 shrink-0" aria-hidden />
From Template
{t("fromTemplate")}
</Link>
</PopoverContent>
</Popover>
+4 -2
View File
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical } from "lucide-react";
@@ -28,6 +29,7 @@ export function DraggableSceneItem({
onDuplicate,
onRename,
}: DraggableSceneItemProps) {
const t = useTranslations("auto.componentsStudioDraggableSceneItem");
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState(scene.name);
const inputRef = useRef<HTMLInputElement>(null);
@@ -80,7 +82,7 @@ export function DraggableSceneItem({
type="button"
ref={setActivatorNodeRef}
className="flex w-6 shrink-0 cursor-grab items-center justify-center text-gray-500 hover:text-gray-300 active:cursor-grabbing"
aria-label={`Drag scene ${scene.name}`}
aria-label={t("dragScene", { name: scene.name })}
{...attributes}
{...listeners}
>
@@ -129,7 +131,7 @@ export function DraggableSceneItem({
}
}}
className="mt-1.5 w-full rounded border border-[#2a2d3e] bg-[#1a1d2e] px-1.5 py-0.5 text-xs text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[#4c6ef5]"
aria-label="Scene name"
aria-label={t("sceneNameLabel")}
/>
) : (
<p
@@ -1,5 +1,6 @@
"use client";
import { useTranslations } from "next-intl";
import { Check, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -17,6 +18,8 @@ export function ProjectSaveIndicator({
onRetry,
className,
}: ProjectSaveIndicatorProps) {
const t = useTranslations("auto.componentsStudioProjectSaveIndicator");
if (status === "idle") return null;
if (status === "pending" || status === "saving") {
@@ -29,7 +32,7 @@ export function ProjectSaveIndicator({
aria-live="polite"
>
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
Saving
{t("saving")}
</span>
);
}
@@ -44,7 +47,7 @@ export function ProjectSaveIndicator({
aria-live="polite"
>
<Check className="h-3.5 w-3.5" aria-hidden />
Saved
{t("saved")}
</span>
);
}
@@ -55,14 +58,14 @@ export function ProjectSaveIndicator({
className={cn("text-xs font-medium text-gray-400", className)}
aria-live="polite"
>
Local save
{t("localSave")}
</span>
);
}
return (
<span className={cn("flex items-center gap-2", className)} aria-live="polite">
<span className="text-xs text-red-400">Save failed</span>
<span className="text-xs text-red-400">{t("saveFailed")}</span>
{onRetry ? (
<Button
type="button"
@@ -71,7 +74,7 @@ export function ProjectSaveIndicator({
className="h-7 border-[#2a2d3e] bg-[#1a1d2e] px-2 text-xs text-gray-200 hover:bg-[#252938]"
onClick={onRetry}
>
Retry
{t("retry")}
</Button>
) : null}
</span>
+5 -3
View File
@@ -1,6 +1,7 @@
"use client";
import { MousePointer2, SlidersHorizontal } from "lucide-react";
import { useTranslations } from "next-intl";
import { CommonLayerControls } from "@/components/studio/properties/CommonLayerControls";
import { ImageLayerProperties } from "@/components/studio/properties/ImageLayerProperties";
@@ -14,6 +15,7 @@ export interface PropertiesPanelProps {
}
export function PropertiesPanel({ className }: PropertiesPanelProps) {
const t = useTranslations("auto.componentsStudioPropertiesPanel");
const scenes = useStudioStore((state) => state.scenes);
const activeSceneId = useStudioStore((state) => state.activeSceneId);
const selectedLayerId = useStudioStore((state) => state.selectedLayerId);
@@ -33,7 +35,7 @@ export function PropertiesPanel({ className }: PropertiesPanelProps) {
>
<div className="flex shrink-0 items-center justify-between border-b border-gray-200 px-3 py-3">
<h2 className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Properties
{t("title")}
</h2>
<SlidersHorizontal className="h-4 w-4 text-gray-400" aria-hidden />
</div>
@@ -44,14 +46,14 @@ export function PropertiesPanel({ className }: PropertiesPanelProps) {
<MousePointer2 className="h-6 w-6 text-gray-300" aria-hidden />
</div>
<p className="text-xs text-gray-400">
Select a layer to edit properties
{t("emptyState")}
</p>
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex-1 space-y-5 overflow-y-auto p-3">
<p className="text-[11px] font-medium capitalize text-gray-400">
{layer.type} layer
{t("layerLabel", { type: layer.type })}
</p>
{layer.type === "text" ? <TextLayerProperties layer={layer} /> : null}
{layer.type === "image" ? (
+25 -23
View File
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Download, Link2, Loader2, RefreshCw } from "lucide-react";
import { apiFetch } from "@/lib/api/fetch";
@@ -49,6 +50,7 @@ export function RenderModal({
scenes,
preset = null,
}: RenderModalProps) {
const t = useTranslations("auto.componentsStudioRenderModal");
const [resolution, setResolution] =
useState<RenderSettings["resolution"]>("1080p");
const [fps, setFps] = useState<RenderSettings["fps"]>(30);
@@ -93,13 +95,13 @@ export function RenderModal({
if (!response.ok) {
setJobStatus("failed");
setErrorMessage("Could not fetch render status.");
setErrorMessage(t("errorFetchStatus"));
return;
}
setProgress(data.progress ?? 0);
setProgressMessage(
data.progressMessage ?? `Rendering${data.progress}%`
data.progressMessage ?? t("renderingProgress", { progress: data.progress })
);
if (data.previewB64) setPreviewB64(data.previewB64);
@@ -112,18 +114,18 @@ export function RenderModal({
if (data.status === "failed") {
setJobStatus("failed");
setErrorMessage(data.errorMessage ?? "Render failed.");
setErrorMessage(data.errorMessage ?? t("errorRenderFailed"));
}
} catch {
setJobStatus("failed");
setErrorMessage("Network error while polling status.");
setErrorMessage(t("errorNetworkPolling"));
}
};
poll();
const intervalId = window.setInterval(poll, 3000);
return () => window.clearInterval(intervalId);
}, [jobStatus, jobId]);
}, [jobStatus, jobId, t]);
const startRender = async () => {
setJobStatus("submitting");
@@ -150,17 +152,17 @@ export function RenderModal({
if (!response.ok || !data.jobId) {
setJobStatus("failed");
setErrorMessage(data.error ?? "Failed to start render.");
setErrorMessage(data.error ?? t("errorStartRender"));
return;
}
setJobId(data.jobId);
setJobStatus("polling");
setProgress(0);
setProgressMessage("Queued for rendering…");
setProgressMessage(t("queued"));
} catch {
setJobStatus("failed");
setErrorMessage("Could not reach render API.");
setErrorMessage(t("errorReachApi"));
}
};
@@ -170,17 +172,17 @@ export function RenderModal({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{presetLabel ?? "Export"}</DialogTitle>
<DialogTitle>{presetLabel ?? t("dialogTitle")}</DialogTitle>
<DialogDescription>
{preset
? RENDER_EXPORT_PRESETS[preset].description
: "Export your project as MP4 via the nexrender pipeline."}
: t("dialogDescription")}
</DialogDescription>
</DialogHeader>
{jobStatus === "completed" && outputUrl ? (
<div className="space-y-4">
<p className="text-sm text-green-400">Your video is ready.</p>
<p className="text-sm text-green-400">{t("videoReady")}</p>
<div className="flex flex-col gap-2">
<a
href={outputUrl}
@@ -188,7 +190,7 @@ export function RenderModal({
className="inline-flex items-center justify-center gap-2 rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]"
>
<Download className="h-4 w-4" />
Download MP4
{t("downloadMp4")}
</a>
<a
href={outputUrl}
@@ -197,7 +199,7 @@ export function RenderModal({
className="inline-flex items-center justify-center gap-2 rounded-lg border border-[#2a2d3e] px-4 py-2.5 text-sm text-gray-200 hover:bg-[#1f2234] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]"
>
<Link2 className="h-4 w-4" />
Share link
{t("shareLink")}
</a>
</div>
<Button
@@ -206,13 +208,13 @@ export function RenderModal({
className="w-full border-[#2a2d3e]"
onClick={() => onOpenChange(false)}
>
Close
{t("close")}
</Button>
</div>
) : jobStatus === "failed" ? (
<div className="space-y-4">
<p className="rounded-lg border border-red-900/50 bg-red-950/40 px-3 py-2 text-sm text-red-300">
{errorMessage ?? "Something went wrong."}
{errorMessage ?? t("errorGeneric")}
</p>
<Button
type="button"
@@ -220,7 +222,7 @@ export function RenderModal({
onClick={startRender}
>
<RefreshCw className="h-4 w-4" />
Retry
{t("retry")}
</Button>
</div>
) : isBusy ? (
@@ -231,7 +233,7 @@ export function RenderModal({
// eslint-disable-next-line @next/next/no-img-element
<img
src={`data:image/png;base64,${previewB64}`}
alt="Render preview"
alt={t("previewAlt")}
className="h-full w-full object-cover"
/>
) : (
@@ -241,12 +243,12 @@ export function RenderModal({
)}
{/* Step badge */}
<div className="absolute bottom-2 right-2 rounded-md bg-black/60 px-2 py-0.5 text-[10px] font-medium text-gray-300 backdrop-blur-sm">
{progressMessage || "Rendering"}
{progressMessage || t("rendering")}
</div>
</div>
<div>
<div className="mb-1 flex justify-between text-xs text-gray-500">
<span>Progress</span>
<span>{t("progress")}</span>
<span>{progress}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-[#1a1d2e]">
@@ -261,7 +263,7 @@ export function RenderModal({
<div className="space-y-4">
<div>
<p className="mb-2 text-xs font-medium text-gray-400">
Resolution
{t("resolution")}
</p>
<div className="flex gap-2">
{RESOLUTIONS.map((item) => (
@@ -282,13 +284,13 @@ export function RenderModal({
</div>
</div>
<div>
<p className="mb-2 text-xs font-medium text-gray-400">Format</p>
<p className="mb-2 text-xs font-medium text-gray-400">{t("format")}</p>
<div className="rounded-lg border border-primary-500 bg-primary-600/20 px-3 py-2 text-center text-xs font-medium text-white">
MP4
</div>
</div>
<div>
<p className="mb-2 text-xs font-medium text-gray-400">FPS</p>
<p className="mb-2 text-xs font-medium text-gray-400">{t("fps")}</p>
<div className="flex gap-2">
{FPS_OPTIONS.map((item) => (
<button
@@ -313,7 +315,7 @@ export function RenderModal({
onClick={startRender}
disabled={scenes.length === 0}
>
Start Rendering
{t("startRendering")}
</Button>
</div>
)}
+3 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Check, Clock, ImageIcon, User } from "lucide-react";
import { getScenePreviewVideoSrc } from "@/lib/template-preview-media";
@@ -52,6 +53,7 @@ export function SceneBrowserCard({
isSelected,
onToggle,
}: SceneBrowserCardProps) {
const t = useTranslations("auto.componentsStudioSceneBrowserCard");
const [hovered, setHovered] = useState(false);
const videoSrc = getScenePreviewVideoSrc(scene.category, scene.id);
@@ -141,7 +143,7 @@ export function SceneBrowserCard({
{!isSelected && hovered && (
<div className="absolute inset-0 flex items-center justify-center">
<span className="rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-semibold text-white shadow-lg">
Select
{t("selectCta")}
</span>
</div>
)}
+14 -12
View File
@@ -1,6 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { LayoutGrid, Search, X } from "lucide-react";
import { SceneBrowserCard } from "@/components/studio/SceneBrowserCard";
@@ -34,6 +35,7 @@ export function SceneBrowserModal({
onOpenChange,
onScenesAdd,
}: SceneBrowserModalProps) {
const t = useTranslations("auto.componentsStudioSceneBrowserModal");
const [categoryId, setCategoryId] = useState<SceneBrowserCategoryId>("all");
const [mediaFilter, setMediaFilter] = useState<SceneBrowserMediaFilter>("all");
const [search, setSearch] = useState("");
@@ -92,13 +94,13 @@ export function SceneBrowserModal({
<DialogHeader className="shrink-0 border-b border-gray-200 px-6 py-4">
<div className="flex items-center justify-between gap-4">
<DialogTitle className="font-heading text-lg font-semibold text-gray-900">
Select Scenes
{t("title")}
</DialogTitle>
<button
type="button"
onClick={handleClose}
className="flex h-8 w-8 items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 hover:text-gray-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600"
aria-label="Close"
aria-label={t("closeAriaLabel")}
>
<X className="h-4 w-4" />
</button>
@@ -111,9 +113,9 @@ export function SceneBrowserModal({
onValueChange={(v) => setMediaFilter(v as SceneBrowserMediaFilter)}
>
<TabsList className="bg-gray-100">
<TabsTrigger value="all">All</TabsTrigger>
<TabsTrigger value="video">Video</TabsTrigger>
<TabsTrigger value="photo">Photo</TabsTrigger>
<TabsTrigger value="all">{t("filterAll")}</TabsTrigger>
<TabsTrigger value="video">{t("filterVideo")}</TabsTrigger>
<TabsTrigger value="photo">{t("filterPhoto")}</TabsTrigger>
</TabsList>
</Tabs>
@@ -121,7 +123,7 @@ export function SceneBrowserModal({
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" aria-hidden />
<input
type="search"
placeholder="Search scenes..."
placeholder={t("searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-9 w-full rounded-lg border border-gray-200 bg-white pl-9 pr-3 text-sm placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600"
@@ -160,7 +162,7 @@ export function SceneBrowserModal({
{filteredScenes.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-16 text-center text-sm text-gray-500">
<LayoutGrid className="h-10 w-10 text-gray-300" aria-hidden />
No scenes match your filters.
{t("emptyState")}
</div>
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
@@ -184,7 +186,7 @@ export function SceneBrowserModal({
{selectedCount > 0 && (
<span className="text-sm text-gray-600">
<span className="font-semibold text-gray-900">{selectedCount}</span>{" "}
scene{selectedCount !== 1 ? "s" : ""} selected
{t("selectedSuffix", { count: selectedCount })}
</span>
)}
{selectedCount > 0 && (
@@ -193,7 +195,7 @@ export function SceneBrowserModal({
onClick={deselectAll}
className="text-sm text-gray-500 underline hover:text-gray-700 focus-visible:outline-none"
>
Deselect All
{t("deselectAll")}
</button>
)}
</div>
@@ -205,7 +207,7 @@ export function SceneBrowserModal({
onClick={handleClose}
className="border-gray-300 text-gray-700 hover:bg-gray-50"
>
Cancel
{t("cancel")}
</Button>
<Button
type="button"
@@ -214,8 +216,8 @@ export function SceneBrowserModal({
className="min-w-[140px] bg-blue-600 hover:bg-blue-700 disabled:opacity-50"
>
{selectedCount === 0
? "Add to Video"
: `Add to Video (${selectedCount})`}
? t("addToVideo")
: t("addToVideoCount", { count: selectedCount })}
</Button>
</div>
</div>
+4 -2
View File
@@ -1,6 +1,7 @@
"use client";
import { Copy, Trash2 } from "lucide-react";
import { useTranslations } from "next-intl";
interface SceneItemActionsProps {
sceneName: string;
@@ -15,6 +16,7 @@ export function SceneItemActions({
onDuplicate,
onDelete,
}: SceneItemActionsProps) {
const t = useTranslations("auto.componentsStudioSceneItemActions");
return (
<div
className="absolute right-1 top-1 flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100"
@@ -27,7 +29,7 @@ export function SceneItemActions({
onDuplicate();
}}
className="flex h-6 w-6 items-center justify-center rounded bg-[#0f111a]/90 text-gray-300 hover:bg-[#1f2234] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]"
aria-label={`Duplicate ${sceneName}`}
aria-label={t("duplicate", { sceneName })}
>
<Copy className="h-3 w-3" aria-hidden />
</button>
@@ -39,7 +41,7 @@ export function SceneItemActions({
onDelete();
}}
className="flex h-6 w-6 items-center justify-center rounded bg-[#0f111a]/90 text-gray-300 hover:bg-red-600/90 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]"
aria-label={`Delete ${sceneName}`}
aria-label={t("delete", { sceneName })}
>
<Trash2 className="h-3 w-3" aria-hidden />
</button>
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import {
Popover,
PopoverContent,
@@ -19,6 +20,7 @@ export function SceneTransitionPicker({
transitionType,
onChange,
}: SceneTransitionPickerProps) {
const t = useTranslations("auto.componentsStudioSceneTransitionPicker");
const [open, setOpen] = useState(false);
const activeOption =
SCENE_TRANSITION_OPTIONS.find((option) => option.id === transitionType) ??
@@ -30,8 +32,8 @@ export function SceneTransitionPicker({
<PopoverTrigger asChild>
<button
type="button"
title="Transition"
aria-label="Transition"
title={t("transition")}
aria-label={t("transition")}
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full border border-[#2a2d3e] bg-[#1a1d2e] text-[#8b91a7] transition-colors hover:border-[#3d4260] hover:bg-[#252938] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]",
transitionType !== "none" && "border-[#4c6ef5]/60 text-[#7b9eff]"
@@ -42,7 +44,7 @@ export function SceneTransitionPicker({
</PopoverTrigger>
<PopoverContent align="center" className="w-44 p-2">
<p className="mb-2 px-1 text-[10px] font-semibold uppercase tracking-wide text-[#5c6278]">
Transition
{t("transition")}
</p>
<ul className="space-y-0.5">
{SCENE_TRANSITION_OPTIONS.map((option) => {
+7 -6
View File
@@ -1,5 +1,6 @@
import Link from "next/link";
import { Monitor } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -9,11 +10,11 @@ export interface StudioMobileGateProps {
variant?: "video" | "image";
}
export function StudioMobileGate({ variant = "video" }: StudioMobileGateProps) {
export async function StudioMobileGate({ variant = "video" }: StudioMobileGateProps) {
const t = await getTranslations("auto.componentsStudioStudioMobileGate");
const title =
variant === "video"
? "The Video Studio requires a desktop browser."
: "The Image Editor requires a desktop browser.";
variant === "video" ? t("titleVideo") : t("titleImage");
return (
<div
@@ -31,14 +32,14 @@ export function StudioMobileGate({ variant = "video" }: StudioMobileGateProps) {
{title}
</h1>
<p className="mt-3 max-w-sm text-sm leading-relaxed text-gray-400">
Please open this project on a desktop or laptop.
{t("description")}
</p>
<Button
asChild
size="lg"
className="mt-8 bg-blue-600 hover:bg-blue-700 focus-visible:ring-blue-500"
>
<Link href="/dashboard">Go to Dashboard</Link>
<Link href="/dashboard">{t("dashboardCta")}</Link>
</Button>
</div>
);
+15 -13
View File
@@ -1,6 +1,7 @@
"use client";
import { useRef, useState, type ChangeEvent } from "react";
import { useTranslations } from "next-intl";
import {
ArrowRight,
Circle,
@@ -23,13 +24,13 @@ import { useStudioStore } from "@/lib/studio-store";
const SHAPE_OPTIONS: {
kind: ShapeKind;
label: string;
labelKey: string;
icon: typeof Square;
config: AddLayerInput;
}[] = [
{
kind: "rect",
label: "Rectangle",
labelKey: "shapeRectangle",
icon: Square,
config: {
type: "shape",
@@ -42,7 +43,7 @@ const SHAPE_OPTIONS: {
},
{
kind: "circle",
label: "Circle",
labelKey: "shapeCircle",
icon: Circle,
config: {
type: "shape",
@@ -55,7 +56,7 @@ const SHAPE_OPTIONS: {
},
{
kind: "line",
label: "Line",
labelKey: "shapeLine",
icon: Minus,
config: {
type: "shape",
@@ -68,7 +69,7 @@ const SHAPE_OPTIONS: {
},
{
kind: "arrow",
label: "Arrow",
labelKey: "shapeArrow",
icon: ArrowRight,
config: {
type: "shape",
@@ -87,6 +88,7 @@ const SHAPE_OPTIONS: {
];
export function StudioToolbar() {
const t = useTranslations("auto.componentsStudioStudioToolbar");
const addLayer = useStudioStore((state) => state.addLayer);
const imageInputRef = useRef<HTMLInputElement>(null);
const videoInputRef = useRef<HTMLInputElement>(null);
@@ -100,7 +102,7 @@ export function StudioToolbar() {
width: 300,
height: 60,
props: {
text: "Edit this text",
text: t("defaultText"),
fontSize: 48,
fill: "#ffffff",
fontFamily: "Inter, sans-serif",
@@ -162,19 +164,19 @@ export function StudioToolbar() {
onChange={handleVideoFile}
/>
<ToolbarIconButton label="Add text" onClick={handleAddText}>
<ToolbarIconButton label={t("addText")} onClick={handleAddText}>
<Type className="h-4 w-4" aria-hidden />
</ToolbarIconButton>
<ToolbarIconButton
label="Add image"
label={t("addImage")}
onClick={() => imageInputRef.current?.click()}
>
<ImageIcon className="h-4 w-4" aria-hidden />
</ToolbarIconButton>
<ToolbarIconButton
label="Add video clip"
label={t("addVideoClip")}
onClick={() => videoInputRef.current?.click()}
>
<Clapperboard className="h-4 w-4" aria-hidden />
@@ -185,7 +187,7 @@ export function StudioToolbar() {
<div className="group relative">
<button
type="button"
aria-label="Add shape"
aria-label={t("addShape")}
className="flex h-9 w-9 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-500 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
<Square className="h-4 w-4" aria-hidden />
@@ -194,12 +196,12 @@ export function StudioToolbar() {
role="tooltip"
className="pointer-events-none absolute left-1/2 top-full z-50 mt-1.5 -translate-x-1/2 whitespace-nowrap rounded-md bg-gray-900 px-2 py-1 text-[10px] font-medium text-white opacity-0 shadow-lg transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
>
Add shape
{t("addShape")}
</span>
</div>
</PopoverTrigger>
<PopoverContent align="center" className="w-40">
{SHAPE_OPTIONS.map(({ kind, label, icon: Icon, config }) => (
{SHAPE_OPTIONS.map(({ kind, labelKey, icon: Icon, config }) => (
<button
key={kind}
type="button"
@@ -210,7 +212,7 @@ export function StudioToolbar() {
className="flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
<Icon className="h-4 w-4 shrink-0" aria-hidden />
{label}
{t(labelKey)}
</button>
))}
</PopoverContent>

Some files were not shown because too many files have changed in this diff Show More