Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1414f06f6 | |||
| 05400947e4 |
@@ -914,6 +914,7 @@
|
||||
"toolShape": "Shape",
|
||||
"toolDraw": "Draw",
|
||||
"toolAi": "AI",
|
||||
"panels": "Adjust",
|
||||
"shapeRectangle": "Rectangle",
|
||||
"shapeCircle": "Circle",
|
||||
"shapeLine": "Line",
|
||||
@@ -1223,6 +1224,8 @@
|
||||
"transitions": "Transitions",
|
||||
"font": "Font",
|
||||
"myWatermark": "My Watermark",
|
||||
"timeline": "Timeline",
|
||||
"preview": "Preview",
|
||||
"toolsNavLabel": "Studio tools",
|
||||
"guideMe": "Guide me",
|
||||
"guideComingSoon": "👋 Guide coming soon!",
|
||||
|
||||
@@ -914,6 +914,7 @@
|
||||
"toolShape": "شکل",
|
||||
"toolDraw": "ترسیم",
|
||||
"toolAi": "هوش مصنوعی",
|
||||
"panels": "تنظیمات",
|
||||
"shapeRectangle": "مستطیل",
|
||||
"shapeCircle": "دایره",
|
||||
"shapeLine": "خط",
|
||||
@@ -1223,6 +1224,8 @@
|
||||
"transitions": "گذارها",
|
||||
"font": "فونت",
|
||||
"myWatermark": "واترمارک من",
|
||||
"timeline": "خط زمان",
|
||||
"preview": "پیشنمایش",
|
||||
"toolsNavLabel": "ابزارهای استودیو",
|
||||
"guideMe": "راهنماییام کن",
|
||||
"guideComingSoon": "👋 راهنما بهزودی ارائه میشود!",
|
||||
|
||||
@@ -171,27 +171,27 @@ export function AdminResource({ config }: { config: ResourceConfig }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm: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>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex w-full items-center gap-2 sm:w-auto">
|
||||
<input
|
||||
className="w-52 rounded-lg border border-[#262b40] bg-[#0c0e1a] px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500"
|
||||
className="w-full rounded-lg border border-[#262b40] bg-[#0c0e1a] px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500 sm:w-52"
|
||||
placeholder="جستجو…" value={query}
|
||||
onChange={(e) => { setQuery(e.target.value); setPage(1); }}
|
||||
/>
|
||||
{config.canCreate && config.fields && (
|
||||
<button className={btn} onClick={openCreate}>+ مورد جدید</button>
|
||||
<button className={`${btn} shrink-0 whitespace-nowrap`} onClick={openCreate}>+ مورد جدید</button>
|
||||
)}
|
||||
</div>
|
||||
</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">
|
||||
<div className={`${card} overflow-x-auto`}>
|
||||
<table className="w-full min-w-[640px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#1e2235] text-start text-xs text-gray-500">
|
||||
{config.columns.map((c) => (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DashboardSidebar } from "@/components/dashboard/DashboardSidebar";
|
||||
import { DashboardSidebarDrawer } from "@/components/dashboard/DashboardSidebarDrawer";
|
||||
|
||||
interface DashboardShellProps {
|
||||
userEmail: string;
|
||||
@@ -16,14 +17,17 @@ export function DashboardShell({
|
||||
children,
|
||||
}: DashboardShellProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen bg-neutral-50">
|
||||
<DashboardSidebar
|
||||
userEmail={userEmail}
|
||||
userName={userName}
|
||||
userId={userId}
|
||||
avatarUrl={avatarUrl}
|
||||
/>
|
||||
<div className="flex min-h-screen min-w-0 flex-1 flex-col">{children}</div>
|
||||
</div>
|
||||
<DashboardSidebarDrawer
|
||||
sidebar={
|
||||
<DashboardSidebar
|
||||
userEmail={userEmail}
|
||||
userName={userName}
|
||||
userId={userId}
|
||||
avatarUrl={avatarUrl}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</DashboardSidebarDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Menu } from "lucide-react";
|
||||
|
||||
import { LogoMark } from "@/components/ui/LogoMark";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Dashboard layout shell with a responsive sidebar: a normal static column on
|
||||
* desktop (lg+), and an off-canvas drawer with a hamburger top bar on mobile.
|
||||
*
|
||||
* The closed-state transform is applied only via `max-lg:` so there is no `lg:`
|
||||
* transform to conflict with — this sidesteps the RTL/`lg:` specificity trap
|
||||
* (see AdminShell) and works for both fa (RTL) and en (LTR).
|
||||
*/
|
||||
export function DashboardSidebarDrawer({
|
||||
sidebar,
|
||||
children,
|
||||
}: {
|
||||
sidebar: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
// Close the drawer after navigating (the dashboard layout persists across routes).
|
||||
useEffect(() => setOpen(false), [pathname]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-neutral-50">
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 transition-transform max-lg:fixed max-lg:inset-y-0 max-lg:start-0 max-lg:z-50",
|
||||
open ? "max-lg:translate-x-0" : "max-lg:-translate-x-full max-lg:rtl:translate-x-full",
|
||||
)}
|
||||
>
|
||||
{sidebar}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/40 lg:hidden"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex min-h-screen min-w-0 flex-1 flex-col">
|
||||
{/* Mobile top bar with hamburger */}
|
||||
<div className="sticky top-0 z-30 flex h-14 items-center gap-3 border-b border-gray-100 bg-white/95 px-4 backdrop-blur lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="menu"
|
||||
className="rounded-lg border border-gray-200 p-1.5 text-neutral-600 hover:bg-neutral-100"
|
||||
>
|
||||
<Menu className="h-5 w-5" aria-hidden />
|
||||
</button>
|
||||
<LogoMark size={28} />
|
||||
<span className="font-heading text-base font-bold text-neutral-900">FlatRender</span>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import { AiRemoveBgModal } from "@/components/image-editor/AiRemoveBgModal";
|
||||
import { ImageCropControls } from "@/components/image-editor/ImageCropControls";
|
||||
import { ImageEditorRightPanel } from "@/components/image-editor/ImageEditorRightPanel";
|
||||
import { ImageEditorToolbar } from "@/components/image-editor/ImageEditorToolbar";
|
||||
import { ImageEditorMobileLayout } from "@/components/image-editor/ImageEditorMobileLayout";
|
||||
import { ImageEditorTopBar } from "@/components/image-editor/ImageEditorTopBar";
|
||||
import { StudioMobileGate } from "@/components/studio/StudioMobileGate";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { useImageProjectPersistence } from "@/hooks/useImageProjectPersistence";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
@@ -34,7 +34,14 @@ export function ImageEditorLayout({ projectId }: ImageEditorLayoutProps) {
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return <StudioMobileGate variant="image" />;
|
||||
return (
|
||||
<ImageEditorMobileLayout
|
||||
projectId={projectId}
|
||||
projectName={projectName}
|
||||
saveStatus={saveStatus}
|
||||
onSaveRetry={retrySave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ComponentProps } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { AiRemoveBgModal } from "@/components/image-editor/AiRemoveBgModal";
|
||||
import { ImageCropControls } from "@/components/image-editor/ImageCropControls";
|
||||
import { ImageEditorRightPanel } from "@/components/image-editor/ImageEditorRightPanel";
|
||||
import {
|
||||
IMAGE_SHAPES,
|
||||
IMAGE_TOOLS,
|
||||
} from "@/components/image-editor/ImageEditorToolbar";
|
||||
import { ImageEditorTopBar } from "@/components/image-editor/ImageEditorTopBar";
|
||||
import { BottomSheet } from "@/components/studio/mobile/BottomSheet";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { useImageEditorStore } from "@/lib/image-editor-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ImageEditorCanvas = dynamic(
|
||||
() =>
|
||||
import("@/components/image-editor/canvas/ImageEditorCanvas").then(
|
||||
(mod) => mod.ImageEditorCanvas,
|
||||
),
|
||||
{ ssr: false, loading: () => <div className="h-full w-full bg-gray-950" /> },
|
||||
);
|
||||
|
||||
type TopBarProps = ComponentProps<typeof ImageEditorTopBar>;
|
||||
|
||||
interface Props {
|
||||
projectId?: string;
|
||||
projectName: TopBarProps["projectName"];
|
||||
saveStatus: TopBarProps["saveStatus"];
|
||||
onSaveRetry: TopBarProps["onSaveRetry"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Phone layout for the Image Editor: canvas fills the viewport, the tool dock
|
||||
* becomes a scrollable bottom bar, and the Adjust/Filters/Layers panel opens as a
|
||||
* bottom sheet. Drawing/selection work via touch (see ImageEditorCanvas).
|
||||
*/
|
||||
export function ImageEditorMobileLayout({ projectId, projectName, saveStatus, onSaveRetry }: Props) {
|
||||
const t = useTranslations("auto.componentsImageEditorImageEditorToolbar");
|
||||
const activeTool = useImageEditorStore((s) => s.activeTool);
|
||||
const setActiveTool = useImageEditorStore((s) => s.setActiveTool);
|
||||
const setPendingShape = useImageEditorStore((s) => s.setPendingShape);
|
||||
const setAiModalOpen = useImageEditorStore((s) => s.setAiModalOpen);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [shapeOpen, setShapeOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex h-[100dvh] w-screen flex-col overflow-hidden bg-gray-950 text-white">
|
||||
<Toaster />
|
||||
<ImageEditorTopBar
|
||||
projectId={projectId}
|
||||
projectName={projectName}
|
||||
saveStatus={saveStatus}
|
||||
onSaveRetry={onSaveRetry}
|
||||
/>
|
||||
<ImageCropControls />
|
||||
<div className="min-h-0 flex-1">
|
||||
<ImageEditorCanvas />
|
||||
</div>
|
||||
|
||||
{/* Bottom tool bar */}
|
||||
<nav className="flex shrink-0 items-center gap-1 overflow-x-auto border-t border-gray-800 bg-gray-900 px-2 py-1.5 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{IMAGE_TOOLS.map((tool) => {
|
||||
const Icon = tool.icon;
|
||||
const active = activeTool === tool.id && tool.id !== "ai";
|
||||
return (
|
||||
<button
|
||||
key={tool.id}
|
||||
type="button"
|
||||
aria-label={t(tool.labelKey)}
|
||||
onClick={() => {
|
||||
if (tool.id === "ai") {
|
||||
setAiModalOpen(true);
|
||||
return;
|
||||
}
|
||||
if (tool.id === "shape") {
|
||||
setActiveTool("shape");
|
||||
setShapeOpen(true);
|
||||
return;
|
||||
}
|
||||
setActiveTool(tool.id);
|
||||
}}
|
||||
className={cn(
|
||||
"flex shrink-0 flex-col items-center gap-0.5 rounded-lg px-3 py-1.5 text-[10px] font-medium transition-colors",
|
||||
active ? "bg-primary-600 text-white" : "text-gray-400 hover:bg-gray-800",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" aria-hidden />
|
||||
<span className="max-w-[56px] truncate">{t(tool.labelKey)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("panels")}
|
||||
onClick={() => setPanelOpen(true)}
|
||||
className="ms-auto flex shrink-0 flex-col items-center gap-0.5 rounded-lg px-3 py-1.5 text-[10px] font-medium text-gray-400 hover:bg-gray-800"
|
||||
>
|
||||
<SlidersHorizontal className="h-5 w-5" aria-hidden />
|
||||
<span>{t("panels")}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<BottomSheet open={panelOpen} onClose={() => setPanelOpen(false)}>
|
||||
<ImageEditorRightPanel />
|
||||
</BottomSheet>
|
||||
|
||||
<BottomSheet open={shapeOpen} onClose={() => setShapeOpen(false)} title={t("toolShape")}>
|
||||
<div className="grid grid-cols-2 gap-2 p-4">
|
||||
{IMAGE_SHAPES.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPendingShape(s.id);
|
||||
setActiveTool("shape");
|
||||
setShapeOpen(false);
|
||||
}}
|
||||
className="rounded-lg border border-gray-200 px-3 py-3 text-sm font-medium text-gray-800 hover:bg-gray-50"
|
||||
>
|
||||
{t(s.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</BottomSheet>
|
||||
|
||||
<AiRemoveBgModal />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export function ImageEditorRightPanel() {
|
||||
const setActivePanelTab = useImageEditorStore((s) => s.setActivePanelTab);
|
||||
|
||||
return (
|
||||
<aside className="flex w-[280px] shrink-0 flex-col border-l border-gray-800 bg-gray-900">
|
||||
<aside className="flex w-full shrink-0 flex-col border-gray-800 bg-gray-900 md:w-[280px] md:border-l">
|
||||
<div className="flex border-b border-gray-800">
|
||||
{TAB_IDS.map((tab) => (
|
||||
<button
|
||||
|
||||
@@ -20,7 +20,7 @@ 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; labelKey: string; icon: typeof MousePointer2 }[] =
|
||||
export const IMAGE_TOOLS: { id: ImageTool; labelKey: string; icon: typeof MousePointer2 }[] =
|
||||
[
|
||||
{ id: "select", labelKey: "toolSelect", icon: MousePointer2 },
|
||||
{ id: "crop", labelKey: "toolCrop", icon: Crop },
|
||||
@@ -29,13 +29,15 @@ const TOOLS: { id: ImageTool; labelKey: string; icon: typeof MousePointer2 }[] =
|
||||
{ id: "draw", labelKey: "toolDraw", icon: Pencil },
|
||||
{ id: "ai", labelKey: "toolAi", icon: Sparkles },
|
||||
];
|
||||
const TOOLS = IMAGE_TOOLS;
|
||||
|
||||
const SHAPES: { id: ImageShapeKind; labelKey: string }[] = [
|
||||
export const IMAGE_SHAPES: { id: ImageShapeKind; labelKey: string }[] = [
|
||||
{ id: "rect", labelKey: "shapeRectangle" },
|
||||
{ id: "circle", labelKey: "shapeCircle" },
|
||||
{ id: "line", labelKey: "shapeLine" },
|
||||
{ id: "arrow", labelKey: "shapeArrow" },
|
||||
];
|
||||
const SHAPES = IMAGE_SHAPES;
|
||||
|
||||
export function ImageEditorToolbar() {
|
||||
const t = useTranslations("auto.componentsImageEditorImageEditorToolbar");
|
||||
|
||||
@@ -73,7 +73,7 @@ export function ImageEditorCanvas() {
|
||||
[scale]
|
||||
);
|
||||
|
||||
const handleStagePointerDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
const handleStagePointerDown = (e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) => {
|
||||
const stage = e.target.getStage();
|
||||
if (!stage) return;
|
||||
const pt = pointerToCanvas(stage);
|
||||
@@ -113,7 +113,7 @@ export function ImageEditorCanvas() {
|
||||
if (e.target === stage) setSelectedLayer(null);
|
||||
};
|
||||
|
||||
const handleStagePointerMove = (e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
const handleStagePointerMove = (e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) => {
|
||||
if (activeTool !== "draw" || drawPoints.length === 0) return;
|
||||
const stage = e.target.getStage();
|
||||
if (!stage) return;
|
||||
@@ -152,7 +152,9 @@ export function ImageEditorCanvas() {
|
||||
>
|
||||
<div
|
||||
className="relative shadow-2xl"
|
||||
style={{ width: stageW, height: stageH }}
|
||||
// touch-action:none lets Konva receive touch drags (draw/select/move)
|
||||
// instead of the browser scrolling/zooming the page.
|
||||
style={{ width: stageW, height: stageH, touchAction: "none" }}
|
||||
>
|
||||
<Stage
|
||||
ref={(node) => registerImageEditorStage(node)}
|
||||
@@ -163,6 +165,9 @@ export function ImageEditorCanvas() {
|
||||
onMouseDown={isCropping ? undefined : handleStagePointerDown}
|
||||
onMousemove={isCropping ? undefined : handleStagePointerMove}
|
||||
onMouseup={isCropping ? undefined : handleStagePointerUp}
|
||||
onTouchStart={isCropping ? undefined : handleStagePointerDown}
|
||||
onTouchMove={isCropping ? undefined : handleStagePointerMove}
|
||||
onTouchEnd={isCropping ? undefined : handleStagePointerUp}
|
||||
className="bg-checkerboard"
|
||||
>
|
||||
<Layer>
|
||||
|
||||
@@ -88,11 +88,11 @@ export function Hero({ className, config }: HeroProps) {
|
||||
|
||||
<motion.div
|
||||
variants={fadeUp}
|
||||
className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row sm:gap-4"
|
||||
className="mx-auto mt-8 flex w-full max-w-xs flex-col items-center justify-center gap-3 sm:max-w-none sm:flex-row sm:gap-4"
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
className="h-12 min-w-[11rem] rounded-lg bg-gradient-to-r from-violet-600 to-rf-blue px-8 text-base font-semibold text-white shadow-md hover:from-violet-700 hover:to-rf-blue/90"
|
||||
className="h-12 w-full rounded-lg bg-gradient-to-r from-violet-600 to-rf-blue px-8 text-base font-semibold text-white shadow-md hover:from-violet-700 hover:to-rf-blue/90 sm:w-auto sm:min-w-[11rem]"
|
||||
asChild
|
||||
>
|
||||
<Link href={ctaHref || "/auth?tab=sign-up"}>{ctaLabel ?? t("cta")}</Link>
|
||||
@@ -100,7 +100,7 @@ export function Hero({ className, config }: HeroProps) {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="h-12 min-w-[11rem] rounded-lg border-2 border-rf-blue bg-white px-8 text-base font-semibold text-rf-blue hover:bg-rf-blue-light"
|
||||
className="h-12 w-full rounded-lg border-2 border-rf-blue bg-white px-8 text-base font-semibold text-rf-blue hover:bg-rf-blue-light sm:w-auto sm:min-w-[11rem]"
|
||||
asChild
|
||||
>
|
||||
<Link href={browseHref || "#templates"}>{browseLabel ?? t("browse")}</Link>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
import { Fragment, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
@@ -10,6 +10,7 @@ import { PricingCheckoutButton } from "@/components/sections/PricingCheckoutButt
|
||||
import {
|
||||
PricingCompareFeatureLabel,
|
||||
PricingCompareValueCell,
|
||||
PricingCompareValueInline,
|
||||
} from "@/components/sections/PricingCompareValue";
|
||||
import type { BillingPeriod, PricingTier } from "@/components/sections/pricing-data";
|
||||
import {
|
||||
@@ -128,7 +129,12 @@ export function PricingCompareTable({
|
||||
if (!lite || !pro || !business) return null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-5xl overflow-x-auto rounded-2xl border border-gray-100 bg-white shadow-sm">
|
||||
<>
|
||||
{/* Mobile: one plan at a time (tabs) — the wide table can't fit a phone. */}
|
||||
<MobileCompare billing={billing} onBillingChange={onBillingChange} />
|
||||
|
||||
{/* Desktop: full comparison table */}
|
||||
<div className="mx-auto hidden w-full max-w-5xl overflow-x-auto rounded-2xl border border-gray-100 bg-white shadow-sm sm:block">
|
||||
<table className="w-full min-w-[760px] border-collapse">
|
||||
<thead className="sticky top-0 z-10 bg-white">
|
||||
<tr className="border-b border-gray-100">
|
||||
@@ -184,6 +190,100 @@ export function PricingCompareTable({
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const TIER_IDS = ["lite", "pro", "business"] as const;
|
||||
type CompareTierId = (typeof TIER_IDS)[number];
|
||||
|
||||
function MobileCompare({
|
||||
billing,
|
||||
onBillingChange,
|
||||
}: PricingCompareTableProps) {
|
||||
const t = useTranslations("auto.componentsSectionsPricingCompareTable");
|
||||
const [active, setActive] = useState<CompareTierId>("pro");
|
||||
const tier = PRICING_TIERS.find((x) => x.id === active);
|
||||
if (!tier) return null;
|
||||
const isStripePlan = tier.id === "pro" || tier.id === "business";
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md sm:hidden">
|
||||
<div className="text-center">
|
||||
<h3 className="bg-gradient-to-r from-blue-600 to-violet-600 bg-clip-text font-heading text-lg font-bold text-transparent">
|
||||
{t("compareHeading")}
|
||||
</h3>
|
||||
<div className="mt-3 flex justify-center">
|
||||
<PricingBillingToggle
|
||||
billing={billing}
|
||||
onChange={onBillingChange}
|
||||
layoutId="pricing-compare-billing-pill-mobile"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plan tabs */}
|
||||
<div className="mt-4 grid grid-cols-3 gap-1 rounded-xl border border-gray-200 bg-gray-50 p-1">
|
||||
{TIER_IDS.map((id) => {
|
||||
const x = PRICING_TIERS.find((p) => p.id === id);
|
||||
if (!x) return null;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setActive(id)}
|
||||
className={cn(
|
||||
"rounded-lg py-2 text-sm font-semibold transition-colors",
|
||||
active === id ? "bg-rf-blue text-white shadow-sm" : "text-neutral-600 hover:text-neutral-900",
|
||||
)}
|
||||
>
|
||||
{x.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Selected plan price + CTA */}
|
||||
<div className="mt-4 rounded-xl border border-gray-100 bg-white p-4 text-center shadow-sm">
|
||||
<PricingAnimatedPrice
|
||||
price={getDisplayPrice(tier, billing)}
|
||||
compareAt={getCompareAtPrice(tier, billing)}
|
||||
billing={billing}
|
||||
size="compact"
|
||||
/>
|
||||
{isStripePlan ? (
|
||||
<PricingCheckoutButton
|
||||
plan={tier.id as PaidPlanId}
|
||||
billing={billing}
|
||||
label={tier.cta}
|
||||
className="mt-3 h-10 w-full rounded-lg bg-rf-blue text-sm font-semibold hover:bg-rf-blue/90"
|
||||
/>
|
||||
) : (
|
||||
<Button variant="outline" className="mt-3 h-10 w-full rounded-lg border-gray-300 text-sm font-semibold" asChild>
|
||||
<Link href="/auth?tab=sign-up">{tier.cta}</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Feature sections for the selected plan */}
|
||||
<div className="mt-4 space-y-4">
|
||||
{COMPARE_SECTIONS.map((section) => (
|
||||
<div key={section.title}>
|
||||
<p className="mb-1.5 px-1 text-xs font-bold uppercase tracking-widest text-gray-500">{section.title}</p>
|
||||
<div className="divide-y divide-gray-100 rounded-xl border border-gray-100 bg-white">
|
||||
{section.rows.map((row) => (
|
||||
<div key={row.feature} className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||||
<PricingCompareFeatureLabel feature={row.feature} tooltip={row.tooltip} />
|
||||
<span className="shrink-0">
|
||||
<PricingCompareValueInline value={row[active]} />
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,24 +29,22 @@ interface PricingCompareValueCellProps {
|
||||
highlighted?: boolean;
|
||||
}
|
||||
|
||||
/** Renders a compare value (✓ / – / text) without a table cell, for the mobile card view. */
|
||||
export function PricingCompareValueInline({ value }: { value: CompareValue }) {
|
||||
if (value === true) return <Check className="h-4 w-4 text-blue-600" aria-hidden />;
|
||||
if (value === false) return <Minus className="h-4 w-4 text-gray-300" aria-hidden />;
|
||||
return <span className="text-sm text-gray-700">{value}</span>;
|
||||
}
|
||||
|
||||
export function PricingCompareValueCell({
|
||||
value,
|
||||
highlighted = false,
|
||||
}: PricingCompareValueCellProps) {
|
||||
return (
|
||||
<td
|
||||
className={cn(
|
||||
"px-4 py-3 text-center",
|
||||
highlighted && "bg-blue-50/30"
|
||||
)}
|
||||
>
|
||||
{value === true ? (
|
||||
<Check className="mx-auto h-4 w-4 text-blue-600" aria-hidden />
|
||||
) : value === false ? (
|
||||
<Minus className="mx-auto h-4 w-4 text-gray-300" aria-hidden />
|
||||
) : (
|
||||
<span className="text-sm text-gray-700">{value}</span>
|
||||
)}
|
||||
<td className={cn("px-4 py-3 text-center", highlighted && "bg-blue-50/30")}>
|
||||
<span className="mx-auto inline-flex items-center justify-center">
|
||||
<PricingCompareValueInline value={value} />
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Mobile bottom sheet: slides up from the bottom with a backdrop. Used to host
|
||||
* the studio/editor side panels (which are desktop side-docks) on phones.
|
||||
*/
|
||||
export function BottomSheet({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
heightClass = "max-h-[72vh]",
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: ReactNode;
|
||||
children: ReactNode;
|
||||
heightClass?: string;
|
||||
}) {
|
||||
// Lock body scroll while open.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-0 z-50 transition-opacity ${open ? "opacity-100" : "pointer-events-none opacity-0"}`}
|
||||
aria-hidden={!open}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className={`absolute inset-x-0 bottom-0 flex flex-col rounded-t-2xl border-t border-gray-200 bg-white shadow-2xl transition-transform duration-200 ${heightClass} ${open ? "translate-y-0" : "translate-y-full"}`}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-2.5">
|
||||
<div className="mx-auto h-1 w-10 rounded-full bg-gray-300" aria-hidden />
|
||||
</div>
|
||||
{title && (
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-2">
|
||||
<span className="text-sm font-semibold text-gray-900">{title}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="close"
|
||||
className="rounded-lg p-1 text-gray-500 hover:bg-gray-100"
|
||||
>
|
||||
<X className="h-5 w-5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ interface StudioSidebarContentProps {
|
||||
|
||||
export function StudioSidebarContent({ activeTool }: StudioSidebarContentProps) {
|
||||
return (
|
||||
<div className="flex h-full w-[240px] shrink-0 flex-col overflow-hidden border-r border-gray-200 bg-white">
|
||||
<div className="flex h-full w-full shrink-0 flex-col overflow-hidden border-gray-200 bg-white md:w-[240px] md:border-r">
|
||||
{activeTool === "scenes" ? <SceneEditSidebarContent /> : null}
|
||||
{activeTool === "audio" ? <AudioSidebarContent /> : null}
|
||||
{activeTool === "tts" ? <TtsSidebarContent /> : null}
|
||||
|
||||
@@ -26,7 +26,7 @@ export type StudioSidebarTool =
|
||||
| "font"
|
||||
| "watermark";
|
||||
|
||||
const MAIN_DOCK_ITEMS: {
|
||||
export const MAIN_DOCK_ITEMS: {
|
||||
id: StudioSidebarTool;
|
||||
labelKey: string;
|
||||
icon: LucideIcon;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { StudioMobileGate } from "@/components/studio/StudioMobileGate";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { CanvasArea } from "@/components/studio/video/CanvasArea";
|
||||
import {
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
} from "@/components/studio/video/StudioSidebarDock";
|
||||
import { StudioSidebarContent } from "@/components/studio/video/StudioSidebarContent";
|
||||
import { StudioTopBar } from "@/components/studio/video/StudioTopBar";
|
||||
import { VideoStudioMobileLayout } from "@/components/studio/video/VideoStudioMobileLayout";
|
||||
import { Timeline } from "@/components/studio/Timeline";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { useStudioProjectPersistence } from "@/hooks/useStudioProjectPersistence";
|
||||
@@ -31,7 +31,15 @@ export function VideoStudioLayout({ projectId }: VideoStudioLayoutProps) {
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return <StudioMobileGate variant="video" />;
|
||||
return (
|
||||
<VideoStudioMobileLayout
|
||||
projectId={projectId}
|
||||
projectName={projectName}
|
||||
onProjectNameChange={setProjectName}
|
||||
saveStatus={saveStatus}
|
||||
usingLocalStorage={usingLocalStorage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ComponentProps } from "react";
|
||||
import { Clock } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { BottomSheet } from "@/components/studio/mobile/BottomSheet";
|
||||
import { CanvasArea } from "@/components/studio/video/CanvasArea";
|
||||
import {
|
||||
MAIN_DOCK_ITEMS,
|
||||
type StudioSidebarTool,
|
||||
} from "@/components/studio/video/StudioSidebarDock";
|
||||
import { StudioSidebarContent } from "@/components/studio/video/StudioSidebarContent";
|
||||
import { StudioTopBar } from "@/components/studio/video/StudioTopBar";
|
||||
import { Timeline } from "@/components/studio/Timeline";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MobileProps {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
onProjectNameChange: (name: string) => void;
|
||||
saveStatus: ComponentProps<typeof StudioTopBar>["saveStatus"];
|
||||
usingLocalStorage: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phone layout for the Video Studio: canvas fills the viewport, the tool dock
|
||||
* becomes a scrollable bottom bar, and each tool's panel + the timeline open as
|
||||
* bottom sheets. Replaces the old "desktop only" gate.
|
||||
*/
|
||||
export function VideoStudioMobileLayout({
|
||||
projectId,
|
||||
projectName,
|
||||
onProjectNameChange,
|
||||
saveStatus,
|
||||
usingLocalStorage,
|
||||
}: MobileProps) {
|
||||
const t = useTranslations("auto.componentsStudioVideoStudioSidebarDock");
|
||||
const [tool, setTool] = useState<StudioSidebarTool | null>(null);
|
||||
const [showTimeline, setShowTimeline] = useState(false);
|
||||
const activeLabel = tool ? t(MAIN_DOCK_ITEMS.find((i) => i.id === tool)?.labelKey ?? "scenes") : "";
|
||||
|
||||
return (
|
||||
<div className="flex h-[100dvh] w-screen flex-col overflow-hidden bg-[#0f111a]">
|
||||
<Toaster />
|
||||
<StudioTopBar
|
||||
projectId={projectId}
|
||||
projectName={projectName}
|
||||
onProjectNameChange={onProjectNameChange}
|
||||
saveStatus={saveStatus}
|
||||
usingLocalStorage={usingLocalStorage}
|
||||
/>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<CanvasArea />
|
||||
</div>
|
||||
|
||||
{/* Bottom tool bar */}
|
||||
<nav
|
||||
className="flex shrink-0 items-center gap-1 overflow-x-auto border-t border-gray-200 bg-white px-2 py-1.5 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
aria-label={t("toolsNavLabel")}
|
||||
>
|
||||
{MAIN_DOCK_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = tool === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setTool(active ? null : item.id)}
|
||||
aria-label={t(item.labelKey)}
|
||||
className={cn(
|
||||
"flex shrink-0 flex-col items-center gap-0.5 rounded-lg px-2.5 py-1.5 text-[10px] font-medium transition-colors",
|
||||
active ? "bg-blue-50 text-blue-600" : "text-gray-500 hover:bg-gray-100",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" aria-hidden />
|
||||
<span className="max-w-[58px] truncate">{t(item.labelKey)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTimeline(true)}
|
||||
aria-label={t("timeline")}
|
||||
className="flex shrink-0 flex-col items-center gap-0.5 rounded-lg px-2.5 py-1.5 text-[10px] font-medium text-gray-500 hover:bg-gray-100"
|
||||
>
|
||||
<Clock className="h-5 w-5" aria-hidden />
|
||||
<span>{t("timeline")}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<BottomSheet open={tool !== null} onClose={() => setTool(null)} title={activeLabel}>
|
||||
{tool && <StudioSidebarContent activeTool={tool} />}
|
||||
</BottomSheet>
|
||||
|
||||
<BottomSheet open={showTimeline} onClose={() => setShowTimeline(false)} title={t("timeline")} heightClass="max-h-[60vh]">
|
||||
<Timeline
|
||||
onOpenTts={() => {
|
||||
setShowTimeline(false);
|
||||
setTool("tts");
|
||||
}}
|
||||
onOpenAudio={() => {
|
||||
setShowTimeline(false);
|
||||
setTool("audio");
|
||||
}}
|
||||
onSceneSelect={() => setShowTimeline(false)}
|
||||
/>
|
||||
</BottomSheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,12 @@ const SIDEBAR_CATEGORIES: {
|
||||
{ id: "music", labelKey: "categoryMusic", icon: Music2 },
|
||||
];
|
||||
|
||||
/** id + labelKey only — shared with the mobile category chip row. */
|
||||
export const VIDEO_SIDEBAR_CATEGORY_IDS: { id: VideoSidebarCategoryId; labelKey: string }[] =
|
||||
SIDEBAR_CATEGORIES.map(({ id, labelKey }) => ({ id, labelKey }));
|
||||
|
||||
export const VIDEO_CATEGORY_NS = "auto.componentsTemplatesVideoVideoTemplatesCategorySidebar";
|
||||
|
||||
interface VideoTemplatesCategorySidebarProps {
|
||||
activeCategory: VideoSidebarCategoryId;
|
||||
onCategoryChange: (id: VideoSidebarCategoryId) => void;
|
||||
|
||||
@@ -5,7 +5,11 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { VideoTemplatesCarouselRow } from "@/components/templates/video/VideoTemplatesCarouselRow";
|
||||
import { VideoTemplatesCategorySidebar } from "@/components/templates/video/VideoTemplatesCategorySidebar";
|
||||
import {
|
||||
VideoTemplatesCategorySidebar,
|
||||
VIDEO_SIDEBAR_CATEGORY_IDS,
|
||||
VIDEO_CATEGORY_NS,
|
||||
} from "@/components/templates/video/VideoTemplatesCategorySidebar";
|
||||
import { VideoTemplatesHero } from "@/components/templates/video/VideoTemplatesHero";
|
||||
import {
|
||||
VideoTemplatesToolbar,
|
||||
@@ -40,6 +44,7 @@ export function VideoTemplatesPageContent({
|
||||
initialCatalog,
|
||||
}: VideoTemplatesPageContentProps = {}) {
|
||||
const t = useTranslations("auto.componentsTemplatesVideoVideoTemplatesPageContent");
|
||||
const tCat = useTranslations(VIDEO_CATEGORY_NS);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const categoryParam = searchParams.get("category");
|
||||
@@ -146,6 +151,27 @@ export function VideoTemplatesPageContent({
|
||||
onSortByChange={setSortBy}
|
||||
/>
|
||||
|
||||
{/* Mobile category chips (the sidebar is desktop-only) */}
|
||||
<div className="mt-4 flex gap-2 overflow-x-auto pb-1 lg:hidden [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{VIDEO_SIDEBAR_CATEGORY_IDS.map((c) => {
|
||||
const active = sidebarCategory === c.id;
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => setSidebarCategory(c.id)}
|
||||
className={`shrink-0 whitespace-nowrap rounded-full border px-3.5 py-1.5 text-sm font-medium transition-colors ${
|
||||
active
|
||||
? "border-primary-600 bg-primary-600 text-white"
|
||||
: "border-gray-200 bg-white text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{tCat(c.labelKey)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="mt-12 rounded-xl border border-dashed border-gray-200 bg-white px-6 py-16 text-center">
|
||||
<p className="font-heading text-lg font-semibold text-gray-900">
|
||||
|
||||
Reference in New Issue
Block a user