feat(studio): phone editing for Video Studio + Image Editor (remove desktop gate)

Replaces the "desktop only" gate on phones with real mobile editing layouts.

Shared:
- BottomSheet (mobile slide-up panel) hosting the desktop side-docks on phones.
- Side panels made width-fluid (w-full on mobile, fixed on md+): StudioSidebarContent,
  ImageEditorRightPanel.

Video Studio (VideoStudioMobileLayout):
- Canvas fills the viewport; the vertical tool dock becomes a scrollable bottom bar;
  each tool's panel + the timeline open as bottom sheets. Exported MAIN_DOCK_ITEMS.

Image Editor (ImageEditorMobileLayout):
- Canvas fills the viewport; toolbar → scrollable bottom bar; Adjust/Filters/Layers
  panel + shape picker open as bottom sheets. Exported IMAGE_TOOLS/IMAGE_SHAPES.
- Touch editing: Stage now handles onTouchStart/Move/End (draw, select, move) with
  touch-action:none; draw-tool stroke works with a finger. Pointer handlers widened
  to MouseEvent | TouchEvent.

i18n: added timeline/preview/panels keys (fa+en, parity verified). Full next build +
tsc clean. (Studio is auth-gated — verify editing on a device.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-12 09:05:44 +03:30
parent 05400947e4
commit a1414f06f6
12 changed files with 352 additions and 12 deletions
+3
View File
@@ -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!",
+3
View File
@@ -914,6 +914,7 @@
"toolShape": "شکل",
"toolDraw": "ترسیم",
"toolAi": "هوش مصنوعی",
"panels": "تنظیمات",
"shapeRectangle": "مستطیل",
"shapeCircle": "دایره",
"shapeLine": "خط",
@@ -1223,6 +1224,8 @@
"transitions": "گذارها",
"font": "فونت",
"myWatermark": "واترمارک من",
"timeline": "خط زمان",
"preview": "پیش‌نمایش",
"toolsNavLabel": "ابزارهای استودیو",
"guideMe": "راهنمایی‌ام کن",
"guideComingSoon": "👋 راهنما به‌زودی ارائه می‌شود!",
@@ -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>
@@ -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>
);
}