feat: full studio build -- light theme, canvas thumbnails, i18n (fa/en)
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { AudioSidebarMusicTab } from "@/components/studio/sidebar/AudioSidebarMusicTab";
|
||||
import { AudioSidebarVoiceoverPane } from "@/components/studio/sidebar/AudioSidebarVoiceoverPane";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function AudioSidebarContent() {
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden bg-white text-gray-900"
|
||||
)}
|
||||
>
|
||||
<Tabs defaultValue="music" className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="shrink-0 border-b border-gray-200 px-3 pt-3">
|
||||
<TabsList className="grid h-9 w-full grid-cols-2 rounded-full bg-gray-100 p-1">
|
||||
<TabsTrigger
|
||||
value="music"
|
||||
className="rounded-full text-xs font-medium data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-500"
|
||||
>
|
||||
Music
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="voiceover"
|
||||
className="rounded-full text-xs font-medium data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-500"
|
||||
>
|
||||
Voiceover
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent
|
||||
value="music"
|
||||
className="mt-0 min-h-0 flex-1 overflow-y-auto p-3 focus-visible:outline-none"
|
||||
>
|
||||
<AudioSidebarMusicTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="voiceover"
|
||||
className="mt-0 min-h-0 flex-1 overflow-y-auto p-3 focus-visible:outline-none"
|
||||
>
|
||||
<AudioSidebarVoiceoverPane />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, type ChangeEvent } from "react";
|
||||
import { Box, HardDrive, Search, UploadCloud } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { MUSIC_GENRE_CARDS } from "@/lib/audio-music-genres";
|
||||
import { useStudioStore } from "@/lib/studio-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function AudioSidebarMusicTab() {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const setAudioTrack = useStudioStore((state) => state.setAudioTrack);
|
||||
const [includeTemplateSfx, setIncludeTemplateSfx] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [activeGenre, setActiveGenre] = useState("all");
|
||||
|
||||
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
setAudioTrack(file.name, reader.result);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
const query = search.trim().toLowerCase();
|
||||
const genres = query
|
||||
? MUSIC_GENRE_CARDS.filter((genre) =>
|
||||
genre.name.toLowerCase().includes(query)
|
||||
)
|
||||
: MUSIC_GENRE_CARDS;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<SourceButton
|
||||
icon={UploadCloud}
|
||||
label="Upload"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
/>
|
||||
<SourceButton icon={Box} label="Dropbox" onClick={() => undefined} />
|
||||
<SourceButton
|
||||
icon={HardDrive}
|
||||
label="Google Drive"
|
||||
onClick={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] leading-snug text-gray-500">
|
||||
Include template sound effect
|
||||
</span>
|
||||
<Switch
|
||||
checked={includeTemplateSfx}
|
||||
onCheckedChange={setIncludeTemplateSfx}
|
||||
aria-label="Include template sound effect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-500"
|
||||
aria-hidden
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search music"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-9 w-full rounded-lg border border-gray-200 bg-white pl-8 pr-3 text-xs text-white placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="library" className="w-full">
|
||||
<TabsList className="grid h-8 w-full grid-cols-2 rounded-full bg-gray-100 p-0.5">
|
||||
<TabsTrigger
|
||||
value="library"
|
||||
className="rounded-full text-[11px] data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-500"
|
||||
>
|
||||
Music library
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="my-music"
|
||||
className="rounded-full text-[11px] data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-500"
|
||||
>
|
||||
My music
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="library" className="mt-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{genres.map((genre) => (
|
||||
<button
|
||||
key={genre.id}
|
||||
type="button"
|
||||
onClick={() => setActiveGenre(genre.id)}
|
||||
className={cn(
|
||||
"flex h-[70px] items-center justify-center rounded-lg px-2 text-center transition-transform hover:scale-[1.02] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500",
|
||||
activeGenre === genre.id && "ring-2 ring-blue-500 ring-offset-1 ring-offset-white"
|
||||
)}
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${genre.gradientFrom}, ${genre.gradientTo})`,
|
||||
}}
|
||||
>
|
||||
<span className="text-[11px] font-bold leading-tight text-white">
|
||||
{genre.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="my-music" className="mt-3">
|
||||
<div className="flex flex-col items-center rounded-xl border border-dashed border-gray-200 bg-white/40 px-3 py-8 text-center">
|
||||
<UploadCloud className="h-8 w-8 text-gray-500" aria-hidden />
|
||||
<p className="mt-3 text-xs text-gray-500">Upload your own music</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mt-3 h-8 border-gray-300 bg-white text-xs text-gray-700 hover:bg-gray-50"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceButton({
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
icon: typeof UploadCloud;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex w-full items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2.5 text-left text-sm text-gray-200 transition-colors hover:border-blue-300 hover:bg-blue-50 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0 text-gray-500" aria-hidden />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { Mic2 } from "lucide-react";
|
||||
|
||||
/** Voiceover tab body (same content as legacy TtsSidebarContent). */
|
||||
export function AudioSidebarVoiceoverPane() {
|
||||
return (
|
||||
<div className="flex flex-col items-center rounded-xl border border-dashed border-gray-200 bg-gray-50 px-4 py-10 text-center">
|
||||
<Mic2 className="h-10 w-10 text-violet-400" aria-hidden />
|
||||
<p className="mt-4 text-sm font-medium text-gray-300">Coming soon</p>
|
||||
<p className="mt-1 max-w-[180px] text-xs text-gray-500">
|
||||
Generate voiceovers from your script directly in the studio.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useStudioStore } from "@/lib/studio-store";
|
||||
|
||||
export function ColorsCustomTab() {
|
||||
const sceneBackgroundColor = useStudioStore(
|
||||
(state) => state.sceneBackgroundColor
|
||||
);
|
||||
const sceneAccentColor = useStudioStore((state) => state.sceneAccentColor);
|
||||
const setSceneBackgroundColor = useStudioStore(
|
||||
(state) => state.setSceneBackgroundColor
|
||||
);
|
||||
const setSceneAccentColor = useStudioStore((state) => state.setSceneAccentColor);
|
||||
const applyPaletteToAllScenes = useStudioStore(
|
||||
(state) => state.applyPaletteToAllScenes
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label
|
||||
htmlFor="colors-main"
|
||||
className="shrink-0 text-xs text-gray-600"
|
||||
>
|
||||
Main Color
|
||||
</label>
|
||||
<input
|
||||
id="colors-main"
|
||||
type="color"
|
||||
value={sceneBackgroundColor}
|
||||
onChange={(event) => setSceneBackgroundColor(event.target.value)}
|
||||
className="h-8 w-12 cursor-pointer rounded border border-gray-200 bg-white p-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label
|
||||
htmlFor="colors-additional"
|
||||
className="shrink-0 text-xs text-gray-600"
|
||||
>
|
||||
Additional Color
|
||||
</label>
|
||||
<input
|
||||
id="colors-additional"
|
||||
type="color"
|
||||
value={sceneAccentColor}
|
||||
onChange={(event) => setSceneAccentColor(event.target.value)}
|
||||
className="h-8 w-12 cursor-pointer rounded border border-gray-200 bg-white p-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full border-gray-200 bg-white text-gray-200 hover:bg-gray-100 hover:text-gray-900"
|
||||
onClick={() =>
|
||||
applyPaletteToAllScenes(sceneBackgroundColor, sceneAccentColor)
|
||||
}
|
||||
>
|
||||
Apply to all scenes
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { COLOR_PALETTES, PALETTE_NAMES } from "@/lib/studio-color-palettes";
|
||||
import { useStudioStore } from "@/lib/studio-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ColorsPalettesTabProps {
|
||||
activeColorIndex: number;
|
||||
onSelectPalette: (index: number) => void;
|
||||
}
|
||||
|
||||
export function ColorsPalettesTab({
|
||||
activeColorIndex,
|
||||
onSelectPalette,
|
||||
}: ColorsPalettesTabProps) {
|
||||
const applyPaletteToAllScenes = useStudioStore(
|
||||
(state) => state.applyPaletteToAllScenes
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto py-2 [scrollbar-width:thin] [scrollbar-color:theme(colors.gray.300)_transparent]">
|
||||
<ul className="space-y-1.5">
|
||||
{COLOR_PALETTES.map((palette, index) => {
|
||||
const isActive = activeColorIndex === index;
|
||||
const name = PALETTE_NAMES[index] ?? `Palette ${index + 1}`;
|
||||
return (
|
||||
<li key={index}>
|
||||
<button
|
||||
type="button"
|
||||
title={name}
|
||||
aria-label={`Apply ${name} palette`}
|
||||
onClick={() => {
|
||||
onSelectPalette(index);
|
||||
applyPaletteToAllScenes(palette[0] ?? "#ffffff", palette[1] ?? "#94a3b8");
|
||||
}}
|
||||
className={cn(
|
||||
"group relative flex h-9 w-full cursor-pointer items-stretch overflow-hidden rounded-lg transition-all",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500",
|
||||
"hover:scale-[1.02] hover:shadow-md",
|
||||
isActive
|
||||
? "ring-2 ring-blue-500 ring-offset-1 ring-offset-white"
|
||||
: "opacity-90 hover:opacity-100"
|
||||
)}
|
||||
>
|
||||
{/* Active left-edge indicator */}
|
||||
{isActive && (
|
||||
<span className="absolute inset-y-0 left-0 z-10 w-1 rounded-l bg-blue-500" />
|
||||
)}
|
||||
|
||||
{/* Colour swatches */}
|
||||
{palette.map((color, ci) => (
|
||||
<span
|
||||
key={ci}
|
||||
className="h-full flex-1"
|
||||
style={{ backgroundColor: color }}
|
||||
aria-hidden
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Name tooltip on hover */}
|
||||
<span className="pointer-events-none absolute inset-x-0 bottom-0 translate-y-full rounded-b-lg bg-gray-800 py-0.5 text-center text-[9px] font-medium text-gray-300 opacity-0 transition-all group-hover:translate-y-0 group-hover:opacity-100">
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { ColorsCustomTab } from "@/components/studio/sidebar/ColorsCustomTab";
|
||||
import { ColorsPalettesTab } from "@/components/studio/sidebar/ColorsPalettesTab";
|
||||
import { ColorsTemplatePreviewCard } from "@/components/studio/sidebar/ColorsTemplatePreviewCard";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { COLOR_PALETTES } from "@/lib/studio-color-palettes";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ColorsSidebarContent() {
|
||||
const [activeColorIndex, setActiveColorIndex] = useState(0);
|
||||
|
||||
const activePalette = COLOR_PALETTES[activeColorIndex] ?? COLOR_PALETTES[0]!;
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden bg-white text-gray-900"
|
||||
)}
|
||||
>
|
||||
<div className="shrink-0 p-3">
|
||||
<ColorsTemplatePreviewCard
|
||||
palette={activePalette}
|
||||
paletteIndex={activeColorIndex}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="palettes" className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="shrink-0 px-3">
|
||||
<TabsList className="grid h-8 w-full grid-cols-2 rounded-full bg-gray-100 p-0.5">
|
||||
<TabsTrigger
|
||||
value="palettes"
|
||||
className="rounded-full text-[11px] data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-500"
|
||||
>
|
||||
Palettes
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="custom"
|
||||
className="rounded-full text-[11px] data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-500"
|
||||
>
|
||||
Custom
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent
|
||||
value="palettes"
|
||||
className="mt-0 flex min-h-0 flex-1 flex-col px-3 focus-visible:outline-none"
|
||||
>
|
||||
<ColorsPalettesTab
|
||||
activeColorIndex={activeColorIndex}
|
||||
onSelectPalette={setActiveColorIndex}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="custom"
|
||||
className="mt-0 flex-1 overflow-y-auto px-3 focus-visible:outline-none"
|
||||
>
|
||||
<ColorsCustomTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { PALETTE_NAMES } from "@/lib/studio-color-palettes";
|
||||
import { contrastTextColor } from "@/lib/studio-color-palettes";
|
||||
|
||||
interface ColorsTemplatePreviewCardProps {
|
||||
palette: string[]; // full 5-color palette
|
||||
paletteIndex: number;
|
||||
}
|
||||
|
||||
export function ColorsTemplatePreviewCard({
|
||||
palette,
|
||||
paletteIndex,
|
||||
}: ColorsTemplatePreviewCardProps) {
|
||||
const [mainColor, accentColor] = palette;
|
||||
const paletteName = PALETTE_NAMES[paletteIndex] ?? `Palette ${paletteIndex + 1}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="shrink-0 overflow-hidden rounded-lg"
|
||||
style={{ background: mainColor }}
|
||||
>
|
||||
{/* Mini canvas preview */}
|
||||
<div className="relative flex h-[88px] items-center justify-end pr-4">
|
||||
{/* Background fill already comes from parent div */}
|
||||
|
||||
{/* Simulated text placeholders — left side */}
|
||||
<div className="absolute left-4 top-1/2 -translate-y-1/2 space-y-1.5">
|
||||
<div
|
||||
className="h-3 w-20 rounded-sm text-[9px] font-bold leading-3 px-1 flex items-center"
|
||||
style={{
|
||||
backgroundColor: `${contrastTextColor(mainColor)}22`,
|
||||
color: contrastTextColor(mainColor),
|
||||
}}
|
||||
>
|
||||
Main Color
|
||||
</div>
|
||||
<div
|
||||
className="h-2.5 w-16 rounded-sm text-[8px] leading-none px-1 flex items-center"
|
||||
style={{
|
||||
backgroundColor: `${contrastTextColor(mainColor)}11`,
|
||||
color: `${contrastTextColor(mainColor)}cc`,
|
||||
}}
|
||||
>
|
||||
Additional
|
||||
</div>
|
||||
<div
|
||||
className="h-2 w-14 rounded-sm"
|
||||
style={{ backgroundColor: accentColor ?? mainColor }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 5-swatch strip — right side */}
|
||||
<div className="flex h-7 w-[90px] overflow-hidden rounded">
|
||||
{palette.map((color, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="flex-1"
|
||||
style={{ backgroundColor: color }}
|
||||
aria-hidden
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Palette name footer */}
|
||||
<div
|
||||
className="px-3 py-1.5 text-[10px] font-semibold"
|
||||
style={{ color: contrastTextColor(mainColor) }}
|
||||
>
|
||||
{paletteName}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { PropertySelect } from "@/components/studio/properties/PropertyControls";
|
||||
import { SidebarPanelShell } from "@/components/studio/sidebar/SidebarPanelShell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FONT_FAMILY_OPTIONS } from "@/lib/studio-layer-props";
|
||||
import { useStudioStore } from "@/lib/studio-store";
|
||||
|
||||
export function FontSidebarContent() {
|
||||
const applyFontFamilyToAllTextLayers = useStudioStore(
|
||||
(state) => state.applyFontFamilyToAllTextLayers
|
||||
);
|
||||
const [fontFamily, setFontFamily] = useState<string>(
|
||||
FONT_FAMILY_OPTIONS[0].value
|
||||
);
|
||||
|
||||
const footer = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full border-gray-200 bg-white text-gray-700 hover:bg-gray-50 hover:text-gray-900"
|
||||
onClick={() => applyFontFamilyToAllTextLayers(fontFamily)}
|
||||
>
|
||||
Apply to all text layers
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarPanelShell title="Font" footer={footer}>
|
||||
<PropertySelect
|
||||
label="Font family"
|
||||
value={fontFamily}
|
||||
options={FONT_FAMILY_OPTIONS.map((item) => ({
|
||||
label: item.label,
|
||||
value: item.value,
|
||||
}))}
|
||||
onChange={setFontFamily}
|
||||
/>
|
||||
</SidebarPanelShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { ImagePlus, Plus, Type } from "lucide-react";
|
||||
|
||||
import { getTextProps, mergeLayerProps } from "@/lib/studio-layer-props";
|
||||
import { useStudioStore } from "@/lib/studio-store";
|
||||
|
||||
export function SceneEditSidebarContent() {
|
||||
const scenes = useStudioStore((state) => state.scenes);
|
||||
const activeSceneId = useStudioStore((state) => state.activeSceneId);
|
||||
const updateLayer = useStudioStore((state) => state.updateLayer);
|
||||
const addLayer = useStudioStore((state) => state.addLayer);
|
||||
|
||||
const activeScene = scenes.find((s) => s.id === activeSceneId);
|
||||
const textLayers = activeScene?.layers.filter((l) => l.type === "text") ?? [];
|
||||
const imageLayers = activeScene?.layers.filter((l) => l.type === "image") ?? [];
|
||||
|
||||
const handleAddText = () => {
|
||||
addLayer({
|
||||
type: "text",
|
||||
x: 240,
|
||||
y: 300,
|
||||
width: 800,
|
||||
height: 80,
|
||||
props: {
|
||||
text: "Your text here",
|
||||
fontSize: 48,
|
||||
fill: "#111827",
|
||||
fontFamily: "Inter, sans-serif",
|
||||
align: "center",
|
||||
bold: false,
|
||||
letterSpacing: 0,
|
||||
lineHeight: 1.2,
|
||||
animation: "none",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-white">
|
||||
{/* Panel header */}
|
||||
<div className="shrink-0 border-b border-gray-200 px-4 py-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
||||
Edit Scene
|
||||
</p>
|
||||
{activeScene && (
|
||||
<p className="mt-0.5 truncate text-[11px] text-gray-500">
|
||||
{activeScene.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scrollable inputs */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Text layers */}
|
||||
{textLayers.length > 0 ? (
|
||||
<div>
|
||||
{textLayers.map((layer, idx) => {
|
||||
const text = getTextProps(layer.props);
|
||||
const MAX = 190;
|
||||
return (
|
||||
<div
|
||||
key={layer.id}
|
||||
className="border-b border-gray-100 px-4 py-3"
|
||||
>
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<label className="flex items-center gap-1.5 text-[11px] font-semibold text-gray-600">
|
||||
<Type className="h-3 w-3 text-gray-400" aria-hidden />
|
||||
{idx === 0 ? "Title" : idx === 1 ? "Subtitle" : `Text ${idx + 1}`}
|
||||
</label>
|
||||
<span
|
||||
className={
|
||||
text.text.length > MAX
|
||||
? "text-[10px] tabular-nums text-red-500"
|
||||
: "text-[10px] tabular-nums text-gray-400"
|
||||
}
|
||||
>
|
||||
{text.text.length}/{MAX}
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={text.text}
|
||||
rows={text.text.length > 60 ? 3 : 2}
|
||||
maxLength={MAX}
|
||||
onChange={(e) =>
|
||||
updateLayer(layer.id, {
|
||||
props: mergeLayerProps(layer.props, {
|
||||
text: e.target.value,
|
||||
}),
|
||||
})
|
||||
}
|
||||
placeholder="Type here…"
|
||||
className="w-full resize-none rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:border-blue-400 focus:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Image layers */}
|
||||
{imageLayers.length > 0 ? (
|
||||
<div>
|
||||
{imageLayers.map((layer, idx) => (
|
||||
<ImageLayerInput
|
||||
key={layer.id}
|
||||
label={`Image ${idx + 1}`}
|
||||
layerProps={layer.props}
|
||||
onReplace={(src) =>
|
||||
updateLayer(layer.id, {
|
||||
props: mergeLayerProps(layer.props, { src }),
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Empty state — no layers at all */}
|
||||
{textLayers.length === 0 && imageLayers.length === 0 && (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<Type className="mx-auto mb-3 h-8 w-8 text-gray-300" aria-hidden />
|
||||
<p className="text-xs text-gray-500">
|
||||
This scene has no content yet.
|
||||
</p>
|
||||
<p className="mt-0.5 text-[11px] text-gray-400">
|
||||
Add a text layer to start editing.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer — add text button */}
|
||||
<div className="shrink-0 border-t border-gray-200 p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddText}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-blue-300 bg-blue-50 px-3 py-2 text-xs font-medium text-blue-600 transition-colors hover:bg-blue-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden />
|
||||
Add Text Layer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Image layer input ─────────────────────────────────────────── */
|
||||
function ImageLayerInput({
|
||||
label,
|
||||
layerProps,
|
||||
onReplace,
|
||||
}: {
|
||||
label: string;
|
||||
layerProps: Record<string, unknown>;
|
||||
onReplace: (src: string) => void;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const src = typeof layerProps.src === "string" ? layerProps.src : null;
|
||||
|
||||
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === "string") onReplace(reader.result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-b border-gray-100 px-4 py-3">
|
||||
<label className="mb-1.5 flex items-center gap-1.5 text-[11px] font-semibold text-gray-600">
|
||||
<ImagePlus className="h-3 w-3 text-gray-400" aria-hidden />
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFile}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="flex w-full items-center gap-2 overflow-hidden rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-left text-xs text-gray-600 transition-colors hover:border-blue-300 hover:bg-blue-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
>
|
||||
{src ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={src} alt="" className="h-8 w-12 shrink-0 rounded object-cover" />
|
||||
) : (
|
||||
<ImagePlus className="h-5 w-5 shrink-0 text-gray-300" aria-hidden />
|
||||
)}
|
||||
<span className="min-w-0 truncate text-gray-500">
|
||||
{src ? "Replace image" : "Upload image"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SidebarPanelShellProps {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SidebarPanelShell({
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
className,
|
||||
}: SidebarPanelShellProps) {
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden bg-white text-gray-900",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="shrink-0 border-b border-gray-200 px-3 py-3">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-3">{children}</div>
|
||||
{footer ? (
|
||||
<div className="shrink-0 border-t border-gray-200 p-3">{footer}</div>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import type { SceneTransition } from "@/lib/studio-types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TransitionPreviewTileProps {
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
transitionId: SceneTransition;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
export function TransitionPreviewTile({
|
||||
label,
|
||||
icon: Icon,
|
||||
transitionId,
|
||||
selected,
|
||||
onSelect,
|
||||
}: TransitionPreviewTileProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"group flex flex-col items-center gap-1.5 rounded-lg border p-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500",
|
||||
selected
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-200 bg-gray-50 hover:border-gray-300"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"relative flex h-12 w-full items-center justify-center overflow-hidden rounded-md bg-gray-200",
|
||||
transitionId === "fade" && "group-hover:[&_.preview]:animate-pulse",
|
||||
transitionId === "slide-left" &&
|
||||
"group-hover:[&_.preview]:animate-[slide-preview_0.4s_ease-out_forwards]",
|
||||
transitionId === "zoom" &&
|
||||
"group-hover:[&_.preview]:animate-[zoom-preview_0.4s_ease-out_forwards]"
|
||||
)}
|
||||
>
|
||||
<span className="preview flex h-6 w-8 items-center justify-center rounded bg-primary-600/80">
|
||||
<Icon className="h-3 w-3 text-white" aria-hidden />
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-gray-500">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import { useStudioStore } from "@/lib/studio-store";
|
||||
import type { SceneTransition } from "@/lib/studio-types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TRANSITION_OPTIONS = [
|
||||
{
|
||||
id: "random",
|
||||
label: "Random Transition",
|
||||
description: "Random",
|
||||
gradientFrom: "#3b82f6",
|
||||
gradientTo: "#8b5cf6",
|
||||
},
|
||||
{
|
||||
id: "none",
|
||||
label: "No Transition",
|
||||
description: "None",
|
||||
gradientFrom: "#374151",
|
||||
gradientTo: "#1f2937",
|
||||
},
|
||||
] as const;
|
||||
|
||||
type TransitionOptionId = (typeof TRANSITION_OPTIONS)[number]["id"];
|
||||
|
||||
function scenesUseTransition(scenes: { transitionType?: SceneTransition }[]): boolean {
|
||||
return scenes.some((scene) => (scene.transitionType ?? "none") !== "none");
|
||||
}
|
||||
|
||||
function optionToTransitionType(id: TransitionOptionId): SceneTransition {
|
||||
return id === "random" ? "fade" : "none";
|
||||
}
|
||||
|
||||
function TransitionOptionCard({
|
||||
option,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
option: (typeof TRANSITION_OPTIONS)[number];
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"w-full cursor-pointer overflow-hidden rounded-lg ring-2 ring-transparent transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500",
|
||||
selected && "ring-[#4c6ef5]"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex aspect-[4/3] flex-col items-center justify-center gap-1"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${option.gradientFrom}, ${option.gradientTo})`,
|
||||
}}
|
||||
>
|
||||
<span className="mx-auto h-2 w-3/4 rounded bg-white/30" aria-hidden />
|
||||
<span className="mx-auto h-2 w-3/4 rounded bg-white/30" aria-hidden />
|
||||
<span className="mx-auto h-2 w-3/4 rounded bg-white/30" aria-hidden />
|
||||
</div>
|
||||
<p className="bg-gray-100 py-1.5 text-center text-[11px] font-semibold text-gray-700">
|
||||
{option.label}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function TransitionsSidebarContent() {
|
||||
const scenes = useStudioStore((state) => state.scenes);
|
||||
const applyTransitionToAllScenes = useStudioStore(
|
||||
(state) => state.applyTransitionToAllScenes
|
||||
);
|
||||
|
||||
const [selectedOption, setSelectedOption] = useState<TransitionOptionId>(() =>
|
||||
scenesUseTransition(scenes) ? "random" : "none"
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedOption(scenesUseTransition(scenes) ? "random" : "none");
|
||||
}, [scenes]);
|
||||
|
||||
const handleSelect = (id: TransitionOptionId) => {
|
||||
setSelectedOption(id);
|
||||
applyTransitionToAllScenes(optionToTransitionType(id));
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-full flex-col overflow-hidden bg-white text-gray-900">
|
||||
<h2 className="shrink-0 border-b border-gray-200 px-3 py-3 text-xs font-semibold uppercase tracking-wide text-gray-400">
|
||||
Transitions
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 p-2">
|
||||
{TRANSITION_OPTIONS.map((option) => (
|
||||
<TransitionOptionCard
|
||||
key={option.id}
|
||||
option={option}
|
||||
selected={selectedOption === option.id}
|
||||
onSelect={() => handleSelect(option.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 px-3 py-2 text-[10px] leading-relaxed text-gray-500">
|
||||
<Info className="mt-0.5 h-3 w-3 shrink-0 text-gray-600" aria-hidden />
|
||||
<p>Applied transitions will be visible on all scenes after export.</p>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { Mic2 } from "lucide-react";
|
||||
|
||||
import { SidebarPanelShell } from "@/components/studio/sidebar/SidebarPanelShell";
|
||||
|
||||
export function TtsSidebarContent() {
|
||||
return (
|
||||
<SidebarPanelShell title="Text to Speech">
|
||||
<div className="flex flex-col items-center rounded-xl border border-dashed border-gray-200 bg-gray-50 py-10 text-center">
|
||||
<Mic2 className="h-10 w-10 text-violet-400" aria-hidden />
|
||||
<p className="mt-4 text-sm font-medium text-gray-300">Coming soon</p>
|
||||
<p className="mt-1 max-w-[180px] text-xs text-gray-500">
|
||||
Generate voiceovers from your script directly in the studio.
|
||||
</p>
|
||||
</div>
|
||||
</SidebarPanelShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { Upload } from "lucide-react";
|
||||
|
||||
import { SidebarPanelShell } from "@/components/studio/sidebar/SidebarPanelShell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const WATERMARK_POSITIONS = [
|
||||
{ id: "top-left", label: "Top left" },
|
||||
{ id: "top-center", label: "Top center" },
|
||||
{ id: "top-right", label: "Top right" },
|
||||
{ id: "middle-left", label: "Middle left" },
|
||||
{ id: "center", label: "Center" },
|
||||
{ id: "middle-right", label: "Middle right" },
|
||||
{ id: "bottom-left", label: "Bottom left" },
|
||||
{ id: "bottom-center", label: "Bottom center" },
|
||||
{ id: "bottom-right", label: "Bottom right" },
|
||||
] as const;
|
||||
|
||||
type WatermarkPosition = (typeof WATERMARK_POSITIONS)[number]["id"];
|
||||
|
||||
export function WatermarkSidebarContent() {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [selectedPosition, setSelectedPosition] =
|
||||
useState<WatermarkPosition>("bottom-right");
|
||||
const [opacity, setOpacity] = useState(80);
|
||||
|
||||
const footer = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full border-gray-200 bg-white text-gray-200 hover:bg-gray-100 hover:text-gray-900"
|
||||
>
|
||||
Apply to all scenes
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarPanelShell title="My Watermark" footer={footer}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="flex w-full flex-col items-center rounded-xl border border-dashed border-gray-200 bg-white/40 px-3 py-8 text-center transition-colors hover:border-blue-300 hover:bg-blue-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
>
|
||||
<Upload className="h-8 w-8 text-gray-400" aria-hidden />
|
||||
<p className="mt-3 text-xs font-medium text-gray-300">
|
||||
Upload your watermark logo
|
||||
</p>
|
||||
<p className="mt-1 text-[10px] text-gray-400">PNG or SVG, max 2MB</p>
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/png,image/svg+xml,.svg"
|
||||
className="hidden"
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<p className="mb-2 text-[11px] font-medium text-gray-500">Position</p>
|
||||
<div className="grid w-fit grid-cols-3 gap-1.5">
|
||||
{WATERMARK_POSITIONS.map((position) => (
|
||||
<button
|
||||
key={position.id}
|
||||
type="button"
|
||||
aria-label={position.label}
|
||||
onClick={() => setSelectedPosition(position.id)}
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500",
|
||||
selectedPosition === position.id
|
||||
? "bg-blue-600"
|
||||
: "bg-gray-200 hover:bg-gray-300"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-[11px] font-medium text-gray-500">Opacity</p>
|
||||
<span className="text-[10px] tabular-nums text-gray-400">
|
||||
{opacity}%
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={[opacity]}
|
||||
onValueChange={([value]) => setOpacity(value)}
|
||||
aria-label="Watermark opacity"
|
||||
/>
|
||||
</div>
|
||||
</SidebarPanelShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user