diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..7c5546e
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,42 @@
+# AGENTS.md
+
+This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
+
+## Development Commands
+
+- **Start development server**: `npm run dev` (opens at http://localhost:3000)
+- **Build for production**: `npm run build`
+- **Start production server**: `npm start`
+- **Lint code**: `npm run lint`
+
+## Project Architecture
+
+This is a Next.js 15 application using the App Router architecture with TypeScript and Tailwind CSS.
+
+### Key Structure
+- `/app/` - Next.js App Router directory containing pages and layouts
+- `/app/layout.tsx` - Root layout with Geist font configuration and global styles
+- `/app/page.tsx` - Homepage component
+- `/app/globals.css` - Global styles with Tailwind CSS and custom CSS variables
+
+### Technology Stack
+- **Framework**: Next.js 15.5.4 with App Router
+- **Language**: TypeScript with strict mode enabled
+- **Styling**: Tailwind CSS 4 with custom CSS variables for theming
+- **UI Components**: shadcn/ui for all UI components
+- **Fonts**: Inter font family (update from Geist)
+- **Linting**: ESLint with Next.js configuration
+
+### Development Guidelines
+- **UI Components**: Always use shadcn/ui components for consistent design system
+- **Font**: Use Inter font family instead of Geist for better readability
+- **Code Style**: Keep code simple, clean, and well-organized
+- **File Organization**: Create proper file and folder structure with clear naming conventions
+
+### Styling System
+- Uses Tailwind CSS with a custom theme configuration in `globals.css`
+- CSS variables for background/foreground colors with automatic dark mode support
+- Font variables should be configured for Inter font family
+
+### Path Configuration
+- `@/*` alias points to the root directory for imports
\ No newline at end of file
diff --git a/app/api/studio/generate/image/route.ts b/app/api/studio/generate/image/route.ts
new file mode 100644
index 0000000..a47e4df
--- /dev/null
+++ b/app/api/studio/generate/image/route.ts
@@ -0,0 +1,108 @@
+import { NextRequest, NextResponse } from "next/server";
+import {
+ FAL_STUDIO_ENDPOINTS,
+ createGenerationErrorResponse,
+ submitImageGeneration,
+} from "@/lib/fal/studio";
+import type {
+ ImageAspectRatio,
+ ImageGenerationSettings,
+ ImageResolution,
+} from "@/types/studio";
+
+const IMAGE_ASPECT_RATIOS: ImageAspectRatio[] = [
+ "auto",
+ "1:1",
+ "16:9",
+ "9:16",
+ "4:3",
+ "3:4",
+];
+
+const IMAGE_RESOLUTIONS: ImageResolution[] = ["1024", "2048", "4096"];
+
+function parseImageSettings(
+ prompt: string,
+ aspectRatioValue: FormDataEntryValue | null,
+ countValue: FormDataEntryValue | null,
+ resolutionValue: FormDataEntryValue | null,
+ attachments: File[]
+): ImageGenerationSettings {
+ const aspectRatio = IMAGE_ASPECT_RATIOS.includes(
+ aspectRatioValue as ImageAspectRatio
+ )
+ ? (aspectRatioValue as ImageAspectRatio)
+ : "1:1";
+ const count = Math.min(
+ 4,
+ Math.max(1, Number.parseInt(String(countValue ?? "1"), 10) || 1)
+ );
+ const resolution = IMAGE_RESOLUTIONS.includes(
+ resolutionValue as ImageResolution
+ )
+ ? (resolutionValue as ImageResolution)
+ : "2048";
+
+ return {
+ aspectRatio,
+ model: "nano-banana-2",
+ count,
+ resolution,
+ isEdit: attachments.length > 0,
+ attachmentCount: attachments.length,
+ };
+}
+
+export async function POST(request: NextRequest) {
+ const formData = await request.formData();
+ const prompt = String(formData.get("prompt") ?? "").trim();
+ const attachments = formData
+ .getAll("attachments")
+ .filter((entry): entry is File => entry instanceof File && entry.size > 0);
+ const settings = parseImageSettings(
+ prompt,
+ formData.get("aspectRatio"),
+ formData.get("count"),
+ formData.get("resolution"),
+ attachments
+ );
+ const providerModel = settings.isEdit
+ ? FAL_STUDIO_ENDPOINTS.imageEdit
+ : FAL_STUDIO_ENDPOINTS.image;
+
+ if (!prompt) {
+ return NextResponse.json(
+ createGenerationErrorResponse(
+ "image",
+ prompt,
+ providerModel,
+ settings,
+ new Error("Prompt is required.")
+ ),
+ { status: 400 }
+ );
+ }
+
+ try {
+ const generation = await submitImageGeneration({
+ prompt,
+ aspectRatio: settings.aspectRatio,
+ count: settings.count,
+ resolution: settings.resolution,
+ attachments,
+ });
+
+ return NextResponse.json(generation.response);
+ } catch (error) {
+ return NextResponse.json(
+ createGenerationErrorResponse(
+ "image",
+ prompt,
+ providerModel,
+ settings,
+ error
+ ),
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/studio/generate/status/route.ts b/app/api/studio/generate/status/route.ts
new file mode 100644
index 0000000..c4af848
--- /dev/null
+++ b/app/api/studio/generate/status/route.ts
@@ -0,0 +1,97 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getGenerationStatus } from "@/lib/fal/studio";
+import type {
+ GenerationJobResponse,
+ ImageGenerationSettings,
+ StudioMode,
+ VideoGenerationSettings,
+} from "@/types/studio";
+
+function parseRequestSettings(
+ mode: StudioMode,
+ payload: string | null
+): ImageGenerationSettings | VideoGenerationSettings {
+ if (!payload) {
+ throw new Error("Missing request settings.");
+ }
+
+ const parsed = JSON.parse(payload) as
+ | ImageGenerationSettings
+ | VideoGenerationSettings;
+
+ if (mode === "image" && parsed.model !== "nano-banana-2") {
+ throw new Error("Invalid image request settings.");
+ }
+
+ if (mode === "video" && parsed.model !== "kling-3") {
+ throw new Error("Invalid video request settings.");
+ }
+
+ return parsed;
+}
+
+export async function POST(request: NextRequest) {
+ const body = (await request.json()) as {
+ requestId?: string;
+ endpointId?: string;
+ type?: StudioMode;
+ prompt?: string;
+ requestSettings?: string;
+ };
+ const requestId = body.requestId ?? null;
+ const endpointId = body.endpointId ?? null;
+ const type = body.type ?? null;
+ const prompt = body.prompt ?? "";
+ const requestSettingsPayload = body.requestSettings ?? null;
+
+ if (!requestId || !endpointId || (type !== "image" && type !== "video")) {
+ return NextResponse.json(
+ {
+ error: "Missing required generation status parameters.",
+ },
+ { status: 400 }
+ );
+ }
+
+ try {
+ const requestSettings = parseRequestSettings(type, requestSettingsPayload);
+ const response = await getGenerationStatus(
+ endpointId,
+ requestId,
+ prompt,
+ type,
+ requestSettings
+ );
+
+ return NextResponse.json(response);
+ } catch (error) {
+ const failure: GenerationJobResponse = {
+ jobId: requestId,
+ status: "failed",
+ type,
+ outputs: [],
+ prompt,
+ providerModel: endpointId,
+ requestSettings:
+ type === "image"
+ ? {
+ aspectRatio: "1:1",
+ model: "nano-banana-2",
+ count: 1,
+ resolution: "2048",
+ isEdit: false,
+ attachmentCount: 0,
+ }
+ : {
+ aspectRatio: "16:9",
+ duration: 5,
+ model: "kling-3",
+ audio: true,
+ },
+ createdAt: new Date().toISOString(),
+ error: error instanceof Error ? error.message : "Failed to fetch status.",
+ };
+
+ return NextResponse.json(failure, { status: 500 });
+ }
+}
diff --git a/app/api/studio/generate/video/route.ts b/app/api/studio/generate/video/route.ts
new file mode 100644
index 0000000..ad38df5
--- /dev/null
+++ b/app/api/studio/generate/video/route.ts
@@ -0,0 +1,114 @@
+import { NextRequest, NextResponse } from "next/server";
+import {
+ FAL_STUDIO_ENDPOINTS,
+ createGenerationErrorResponse,
+ submitVideoGeneration,
+} from "@/lib/fal/studio";
+import type {
+ VideoAspectRatio,
+ VideoGenerationSettings,
+} from "@/types/studio";
+
+const VIDEO_ASPECT_RATIOS: VideoAspectRatio[] = ["16:9", "9:16", "1:1"];
+const VIDEO_DURATIONS = new Set([3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]);
+
+function parseVideoSettings(
+ prompt: string,
+ aspectRatioValue: FormDataEntryValue | null,
+ durationValue: FormDataEntryValue | null,
+ audioValue: FormDataEntryValue | null,
+ firstFrame: File | null,
+ lastFrame: File | null
+): VideoGenerationSettings {
+ const aspectRatio = VIDEO_ASPECT_RATIOS.includes(
+ aspectRatioValue as VideoAspectRatio
+ )
+ ? (aspectRatioValue as VideoAspectRatio)
+ : "16:9";
+ const durationCandidate =
+ Number.parseInt(String(durationValue ?? "5"), 10) || 5;
+ const duration = VIDEO_DURATIONS.has(durationCandidate) ? durationCandidate : 5;
+ const audio = String(audioValue ?? "true") === "true";
+
+ return {
+ aspectRatio,
+ duration,
+ model: "kling-3",
+ audio,
+ firstFrame: firstFrame?.name,
+ lastFrame: lastFrame?.name ?? undefined,
+ };
+}
+
+export async function POST(request: NextRequest) {
+ const formData = await request.formData();
+ const prompt = String(formData.get("prompt") ?? "").trim();
+ const firstFrameEntry = formData.get("firstFrame");
+ const lastFrameEntry = formData.get("lastFrame");
+ const firstFrame =
+ firstFrameEntry instanceof File && firstFrameEntry.size > 0
+ ? firstFrameEntry
+ : null;
+ const lastFrame =
+ lastFrameEntry instanceof File && lastFrameEntry.size > 0
+ ? lastFrameEntry
+ : null;
+ const settings = parseVideoSettings(
+ prompt,
+ formData.get("aspectRatio"),
+ formData.get("duration"),
+ formData.get("audio"),
+ firstFrame,
+ lastFrame
+ );
+
+ if (!prompt) {
+ return NextResponse.json(
+ createGenerationErrorResponse(
+ "video",
+ prompt,
+ FAL_STUDIO_ENDPOINTS.video,
+ settings,
+ new Error("Prompt is required.")
+ ),
+ { status: 400 }
+ );
+ }
+
+ if (!firstFrame) {
+ return NextResponse.json(
+ createGenerationErrorResponse(
+ "video",
+ prompt,
+ FAL_STUDIO_ENDPOINTS.video,
+ settings,
+ new Error("A first frame is required for Kling 3 image-to-video.")
+ ),
+ { status: 400 }
+ );
+ }
+
+ try {
+ const generation = await submitVideoGeneration({
+ prompt,
+ aspectRatio: settings.aspectRatio,
+ duration: settings.duration,
+ audio: settings.audio,
+ firstFrame,
+ lastFrame,
+ });
+
+ return NextResponse.json(generation.response);
+ } catch (error) {
+ return NextResponse.json(
+ createGenerationErrorResponse(
+ "video",
+ prompt,
+ FAL_STUDIO_ENDPOINTS.video,
+ settings,
+ error
+ ),
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/studio/page.tsx b/app/studio/page.tsx
new file mode 100644
index 0000000..a0934c5
--- /dev/null
+++ b/app/studio/page.tsx
@@ -0,0 +1,11 @@
+import type { Metadata } from "next";
+import StudioPage from "./studio-page";
+
+export const metadata: Metadata = {
+ title: "Studio | Invook",
+ description: "Create AI-generated images and videos with Invook Studio",
+};
+
+export default function Studio() {
+ return ;
+}
diff --git a/app/studio/studio-page.tsx b/app/studio/studio-page.tsx
new file mode 100644
index 0000000..d6dbf08
--- /dev/null
+++ b/app/studio/studio-page.tsx
@@ -0,0 +1,42 @@
+"use client";
+
+import { useEffect } from "react";
+import { useRouter } from "next/navigation";
+import { StudioCanvas } from "@/components/studio/studio-canvas";
+import { StudioPromptBar } from "@/components/studio/studio-prompt-bar";
+import { useStudioGeneration } from "@/hooks/use-studio-generation";
+import { useAuthStore } from "@/store/useAuthStore";
+
+export default function StudioPage() {
+ const { isAuthenticated, isLoading } = useAuthStore();
+ const router = useRouter();
+ const { generate, state } = useStudioGeneration();
+
+ useEffect(() => {
+ if (!isLoading && !isAuthenticated) {
+ router.push(`/sign-in?redirect=${encodeURIComponent("/studio")}`);
+ }
+ }, [isAuthenticated, isLoading, router]);
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (!isAuthenticated) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ );
+}
diff --git a/components/conditional-layout.tsx b/components/conditional-layout.tsx
index 39024d4..e93ed65 100644
--- a/components/conditional-layout.tsx
+++ b/components/conditional-layout.tsx
@@ -13,12 +13,13 @@ export function ConditionalLayout({
const isAuthPage = pathname?.startsWith("/auth") || pathname === "/sign-in" || pathname === "/sign-up";
const isExplorePage = pathname?.startsWith("/explore");
const isSharePage = pathname?.startsWith("/share");
+ const isStudioPage = pathname?.startsWith("/studio");
return (
<>
- {!isAuthPage && !isExplorePage && !isSharePage && }
+ {!isAuthPage && !isExplorePage && !isSharePage && !isStudioPage && }
{children}
- {!isAuthPage && !isSharePage && }
+ {!isAuthPage && !isSharePage && !isStudioPage && }
>
);
}
\ No newline at end of file
diff --git a/components/studio/duration-picker.tsx b/components/studio/duration-picker.tsx
new file mode 100644
index 0000000..8c86441
--- /dev/null
+++ b/components/studio/duration-picker.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import { HugeiconsIcon } from "@hugeicons/react";
+import { Clock01Icon } from "@hugeicons/core-free-icons";
+import { Slider } from "@/components/ui/slider";
+import { SettingPicker } from "./setting-picker";
+
+interface DurationPickerProps {
+ value: number;
+ onChange: (value: number) => void;
+}
+
+const MIN_DURATION = 3;
+const MAX_DURATION = 15;
+
+export function DurationPicker({ value, onChange }: DurationPickerProps) {
+ return (
+ ,
+ },
+ ]}
+ onChange={() => {}}
+ renderPanel={() => (
+
+
+
+ Choose duration
+
+
+ {value}s
+
+
onChange(nextValue)}
+ className="[&_span[data-slot=slider-range]]:bg-foreground [&_span[data-slot=slider-thumb]]:size-4 [&_span[data-slot=slider-thumb]]:border-foreground [&_span[data-slot=slider-thumb]]:bg-background [&_span[data-slot=slider-track]]:bg-border"
+ />
+
+
+ )}
+ />
+ );
+}
diff --git a/components/studio/frame-upload.tsx b/components/studio/frame-upload.tsx
new file mode 100644
index 0000000..2f0d6be
--- /dev/null
+++ b/components/studio/frame-upload.tsx
@@ -0,0 +1,160 @@
+"use client";
+
+import { useRef, useState, type DragEvent } from "react";
+import { Plus, X } from "lucide-react";
+import Image from "next/image";
+import {
+ draggedStudioAssetToFile,
+ hasDraggedStudioAsset,
+ readDraggedStudioAsset,
+} from "@/lib/studio-drag";
+import { cn } from "@/lib/utils";
+
+interface FrameUploadProps {
+ label: string;
+ preview: string | null;
+ onUpload: (file: File | null) => void;
+ compact?: boolean;
+ className?: string;
+}
+
+export function FrameUpload({
+ label,
+ preview,
+ onUpload,
+ compact = false,
+ className,
+}: FrameUploadProps) {
+ const inputRef = useRef(null);
+ const [isDropTarget, setIsDropTarget] = useState(false);
+ const compactLabel = label.toUpperCase().replace(" ", "\n");
+ const mobileCompactLabel = label;
+
+ const handleDrop = async (event: DragEvent) => {
+ const directFile = Array.from(event.dataTransfer.files).find((file) =>
+ file.type.startsWith("image/")
+ );
+
+ if (directFile) {
+ onUpload(directFile);
+ return;
+ }
+
+ const asset = readDraggedStudioAsset(event.dataTransfer);
+
+ if (!asset) {
+ return;
+ }
+
+ const file = await draggedStudioAssetToFile(asset);
+
+ if (file.type.startsWith("image/")) {
+ onUpload(file);
+ }
+ };
+
+ return (
+
+
{
+ const file = e.target.files?.[0] ?? null;
+ onUpload(file);
+ e.target.value = "";
+ }}
+ />
+
+ {preview && (
+
+ )}
+
+ );
+}
diff --git a/components/studio/generation-display.tsx b/components/studio/generation-display.tsx
new file mode 100644
index 0000000..1b73417
--- /dev/null
+++ b/components/studio/generation-display.tsx
@@ -0,0 +1,95 @@
+"use client";
+
+import { motion } from "framer-motion";
+import Image from "next/image";
+import { setDraggedStudioAsset } from "@/lib/studio-drag";
+import { cn } from "@/lib/utils";
+import { useStudioStore } from "@/store/useStudioStore";
+import type { StudioCanvasAsset } from "@/types/studio";
+
+interface GenerationDisplayProps {
+ assets: StudioCanvasAsset[];
+}
+
+function getAspectRatioValue(aspectRatio?: string) {
+ if (!aspectRatio || aspectRatio === "auto") {
+ return undefined;
+ }
+
+ return aspectRatio.replace(":", " / ");
+}
+
+export function GenerationDisplay({ assets }: GenerationDisplayProps) {
+ const openViewer = useStudioStore((state) => state.openViewer);
+ const isSingle = assets.length === 1;
+
+ return (
+
+ {assets.map((asset, index) => {
+ const aspectRatio = getAspectRatioValue(asset.aspectRatio);
+
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/components/studio/generation-loading.tsx b/components/studio/generation-loading.tsx
new file mode 100644
index 0000000..0bcab94
--- /dev/null
+++ b/components/studio/generation-loading.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import { motion } from "framer-motion";
+import { cn } from "@/lib/utils";
+import { useStudioStore } from "@/store/useStudioStore";
+
+function getAspectRatioValue(aspectRatio?: string) {
+ if (!aspectRatio || aspectRatio === "auto") {
+ return undefined;
+ }
+
+ return aspectRatio.replace(":", " / ");
+}
+
+function getMediaWidthClass(aspectRatio?: string) {
+ switch (aspectRatio) {
+ case "9 / 16":
+ return "max-w-[14rem]";
+ case "1 / 1":
+ return "max-w-lg";
+ default:
+ return "max-w-xl";
+ }
+}
+
+export function GenerationLoading() {
+ const { mode, imageAspectRatio, imageCount, videoAspectRatio } = useStudioStore();
+
+ const aspectRatio = getAspectRatioValue(
+ mode === "image" ? imageAspectRatio : videoAspectRatio
+ );
+ const skeletonCount = mode === "image" ? imageCount : 1;
+ const isSingle = skeletonCount === 1;
+
+ return (
+
+ {Array.from({ length: skeletonCount }).map((_, index) => (
+
+ ))}
+
+ );
+}
diff --git a/components/studio/image-settings.tsx b/components/studio/image-settings.tsx
new file mode 100644
index 0000000..df57991
--- /dev/null
+++ b/components/studio/image-settings.tsx
@@ -0,0 +1,145 @@
+"use client";
+
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ ArtificialIntelligence01Icon,
+ AspectRatioIcon,
+ MinusSignCircleIcon,
+ PlusSignCircleIcon,
+ SparklesIcon,
+} from "@hugeicons/core-free-icons";
+import { useStudioStore } from "@/store/useStudioStore";
+import { SettingPicker, type SettingPickerOption } from "./setting-picker";
+import type {
+ ImageAIModel,
+ ImageAspectRatio,
+ ImageResolution,
+} from "@/types/studio";
+
+const ASPECT_RATIOS: SettingPickerOption[] = [
+ {
+ value: "auto",
+ label: "Auto",
+ description: "Let the model choose the best framing for your prompt.",
+ icon: ,
+ },
+ {
+ value: "1:1",
+ label: "1:1",
+ description: "Square framing for covers, tiles, and balanced compositions.",
+ icon: ,
+ },
+ {
+ value: "16:9",
+ label: "16:9",
+ description: "Wide cinematic framing for landscapes and scene building.",
+ icon: ,
+ },
+ {
+ value: "9:16",
+ label: "9:16",
+ description: "Vertical framing for phone-first visuals and stories.",
+ icon: ,
+ },
+ {
+ value: "4:3",
+ label: "4:3",
+ description: "Classic frame with more height than widescreen.",
+ icon: ,
+ },
+ {
+ value: "3:4",
+ label: "3:4",
+ description: "Portrait-oriented frame with a print-like feel.",
+ icon: ,
+ },
+];
+
+const IMAGE_MODELS: SettingPickerOption[] = [
+ {
+ value: "nano-banana-2",
+ label: "Nano Banana 2",
+ description: "Fast image generation with clean general-purpose results.",
+ icon: ,
+ },
+];
+
+const RESOLUTIONS: SettingPickerOption[] = [
+ {
+ value: "1024",
+ label: "1K",
+ description: "Fast. Quick generation with good resolution.",
+ icon: ,
+ },
+ {
+ value: "2048",
+ label: "2K",
+ description: "Balanced. Recommended for most use cases.",
+ icon: ,
+ },
+ {
+ value: "4096",
+ label: "4K",
+ description: "Ultra. Highest detail with longer processing.",
+ icon: ,
+ },
+];
+
+export function ImageSettings() {
+ const {
+ imageAspectRatio,
+ setImageAspectRatio,
+ imageModel,
+ setImageModel,
+ imageCount,
+ setImageCount,
+ imageResolution,
+ setImageResolution,
+ } = useStudioStore();
+
+ return (
+
+
setImageModel(value as ImageAIModel)}
+ className="w-[7.1rem] md:w-[9.75rem]"
+ />
+
+
+
+
+ {imageCount}
+
+
+
+
+ setImageAspectRatio(value as ImageAspectRatio)}
+ />
+
+ setImageResolution(value as ImageResolution)}
+ />
+
+ );
+}
diff --git a/components/studio/mode-switcher.tsx b/components/studio/mode-switcher.tsx
new file mode 100644
index 0000000..99925ac
--- /dev/null
+++ b/components/studio/mode-switcher.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import { HugeiconsIcon } from "@hugeicons/react";
+import { Image01Icon, Film01Icon } from "@hugeicons/core-free-icons";
+import type { IconSvgElement } from "@hugeicons/react";
+import { useStudioStore } from "@/store/useStudioStore";
+import { cn } from "@/lib/utils";
+import type { StudioMode } from "@/types/studio";
+
+const modes: { value: StudioMode; icon: IconSvgElement; label: string }[] = [
+ { value: "image", icon: Image01Icon, label: "Image" },
+ { value: "video", icon: Film01Icon, label: "Video" },
+];
+
+interface ModeSwitcherProps {
+ orientation?: "vertical" | "horizontal";
+ className?: string;
+}
+
+export function ModeSwitcher({
+ orientation = "vertical",
+ className,
+}: ModeSwitcherProps) {
+ const { mode, setMode } = useStudioStore();
+ const isHorizontal = orientation === "horizontal";
+
+ return (
+
+ {modes.map(({ value, icon, label }) => (
+
+ ))}
+
+ );
+}
diff --git a/components/studio/prompt-footer.tsx b/components/studio/prompt-footer.tsx
new file mode 100644
index 0000000..cfca900
--- /dev/null
+++ b/components/studio/prompt-footer.tsx
@@ -0,0 +1,118 @@
+"use client";
+
+import { motion } from "framer-motion";
+import { cn } from "@/lib/utils";
+import { useStudioStore } from "@/store/useStudioStore";
+import { FrameUpload } from "./frame-upload";
+
+interface PromptFooterProps {
+ isGenerating: boolean;
+ onGenerate: () => void | Promise;
+ className?: string;
+}
+
+export function PromptFooter({
+ isGenerating,
+ onGenerate,
+ className,
+}: PromptFooterProps) {
+ const {
+ prompt,
+ mode,
+ firstFramePreview,
+ setFirstFrame,
+ lastFramePreview,
+ setLastFrame,
+ } = useStudioStore();
+
+ const canSubmit =
+ prompt.trim().length > 0 &&
+ !isGenerating &&
+ (mode === "image" || Boolean(firstFramePreview));
+
+ return (
+
+
+ {mode === "video" ? (
+
+
+
+ {
+ void onGenerate();
+ }}
+ disabled={!canSubmit}
+ className="flex h-11 min-w-[7.75rem] shrink-0 items-center justify-center rounded-xl bg-[#F54E00] px-5 text-sm font-semibold text-white shadow-[0_12px_26px_rgba(245,78,0,0.28)] transition-colors hover:bg-[#e04500] disabled:cursor-not-allowed disabled:opacity-30"
+ >
+ {isGenerating ? "Working..." : "Generate"}
+
+
+ ) : (
+
+ {
+ void onGenerate();
+ }}
+ disabled={!canSubmit}
+ className="flex h-11 w-full items-center justify-center rounded-xl bg-[#F54E00] px-4 text-sm font-semibold text-white shadow-[0_12px_26px_rgba(245,78,0,0.28)] transition-colors hover:bg-[#e04500] disabled:cursor-not-allowed disabled:opacity-30"
+ >
+ {isGenerating ? "Working..." : "Generate"}
+
+
+ )}
+
+
+ {mode === "video" ? (
+
+ ) : null}
+ {mode === "video" ? (
+
+ ) : null}
+
+ {
+ void onGenerate();
+ }}
+ disabled={!canSubmit}
+ className="flex h-[90px] w-24 items-center justify-center rounded-xl bg-[#F54E00] px-4 text-sm font-semibold text-white transition-colors hover:bg-[#e04500] disabled:cursor-not-allowed disabled:opacity-30"
+ >
+ {isGenerating ? "Working..." : "Generate"}
+
+
+
+
+ );
+}
diff --git a/components/studio/prompt-input.tsx b/components/studio/prompt-input.tsx
new file mode 100644
index 0000000..6154b6d
--- /dev/null
+++ b/components/studio/prompt-input.tsx
@@ -0,0 +1,196 @@
+"use client";
+
+import {
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+ type DragEvent,
+ type ReactNode,
+ type KeyboardEvent,
+} from "react";
+import Image from "next/image";
+import { Plus } from "lucide-react";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ Cancel01Icon,
+} from "@hugeicons/core-free-icons";
+import {
+ draggedStudioAssetToFile,
+ hasDraggedStudioAsset,
+ readDraggedStudioAsset,
+} from "@/lib/studio-drag";
+import { useStudioStore } from "@/store/useStudioStore";
+
+interface PromptInputProps {
+ children?: ReactNode;
+ isGenerating: boolean;
+ onGenerate: () => void | Promise;
+}
+
+const MIN_TEXTAREA_HEIGHT = 24;
+const MAX_TEXTAREA_HEIGHT = 320;
+
+export function PromptInput({
+ children,
+ isGenerating,
+ onGenerate,
+}: PromptInputProps) {
+ const {
+ prompt,
+ setPrompt,
+ attachments,
+ addAttachment,
+ removeAttachment,
+ } = useStudioStore();
+ const textareaRef = useRef(null);
+ const fileInputRef = useRef(null);
+ const [isDropTarget, setIsDropTarget] = useState(false);
+
+ useEffect(() => {
+ const textarea = textareaRef.current;
+
+ if (!textarea) {
+ return;
+ }
+
+ textarea.style.height = "0px";
+ const nextHeight = Math.min(
+ MAX_TEXTAREA_HEIGHT,
+ Math.max(MIN_TEXTAREA_HEIGHT, textarea.scrollHeight)
+ );
+ textarea.style.height = `${nextHeight}px`;
+ textarea.style.overflowY =
+ textarea.scrollHeight > MAX_TEXTAREA_HEIGHT ? "auto" : "hidden";
+ }, [prompt]);
+
+ const addDroppedFiles = useCallback(
+ async (event: DragEvent) => {
+ const files = Array.from(event.dataTransfer.files).filter((file) =>
+ file.type.startsWith("image/")
+ );
+
+ if (files.length > 0) {
+ files.forEach(addAttachment);
+ return;
+ }
+
+ const asset = readDraggedStudioAsset(event.dataTransfer);
+
+ if (!asset) {
+ return;
+ }
+
+ const file = await draggedStudioAssetToFile(asset);
+
+ if (file.type.startsWith("image/")) {
+ addAttachment(file);
+ }
+ },
+ [addAttachment]
+ );
+
+ const handleKeyDown = async (event: KeyboardEvent) => {
+ if (event.key === "Enter" && !event.shiftKey) {
+ event.preventDefault();
+
+ if (!isGenerating) {
+ await onGenerate();
+ }
+ }
+ };
+
+ return (
+ {
+ if (
+ event.dataTransfer.files.length > 0 ||
+ hasDraggedStudioAsset(event.dataTransfer)
+ ) {
+ event.preventDefault();
+ event.dataTransfer.dropEffect = "copy";
+ setIsDropTarget(true);
+ }
+ }}
+ onDragLeave={() => setIsDropTarget(false)}
+ onDrop={(event) => {
+ event.preventDefault();
+ setIsDropTarget(false);
+ void addDroppedFiles(event);
+ }}
+ >
+
+
+
+ {attachments.length > 0 ? (
+
+ {attachments.map((attachment, index) => (
+
+
+
+
+
+
+ ))}
+
+ ) : null}
+
+
+
{
+ const files = event.target.files;
+
+ if (files) {
+ Array.from(files).forEach(addAttachment);
+ }
+
+ event.target.value = "";
+ }}
+ />
+
+
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/components/studio/setting-picker.tsx b/components/studio/setting-picker.tsx
new file mode 100644
index 0000000..47639f6
--- /dev/null
+++ b/components/studio/setting-picker.tsx
@@ -0,0 +1,227 @@
+"use client";
+
+import {
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import { createPortal } from "react-dom";
+import { Check } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+export interface SettingPickerOption {
+ value: string;
+ label: string;
+ description?: string;
+ icon?: React.ReactNode;
+}
+
+interface SettingPickerProps {
+ label: string;
+ value: string;
+ options: SettingPickerOption[];
+ onChange: (value: string) => void;
+ className?: string;
+ panelClassName?: string;
+ title?: string;
+ renderPanel?: (context: {
+ close: () => void;
+ selectedOption?: SettingPickerOption;
+ }) => ReactNode;
+}
+
+export function SettingPicker({
+ label,
+ value,
+ options,
+ onChange,
+ className,
+ panelClassName,
+ title,
+ renderPanel,
+}: SettingPickerProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const containerRef = useRef(null);
+ const triggerRef = useRef(null);
+ const panelRef = useRef(null);
+ const [panelStyle, setPanelStyle] = useState<{
+ bottom: number;
+ left: number;
+ width: number;
+ maxHeight: number;
+ } | null>(null);
+
+ const selectedOption = useMemo(
+ () => options.find((option) => option.value === value) ?? options[0],
+ [options, value]
+ );
+
+ useLayoutEffect(() => {
+ if (!isOpen || !triggerRef.current) {
+ return;
+ }
+
+ const updatePosition = () => {
+ const triggerRect = triggerRef.current?.getBoundingClientRect();
+
+ if (!triggerRect) {
+ return;
+ }
+
+ const viewportPadding = 12;
+ const preferredWidth = Math.min(288, window.innerWidth - viewportPadding * 2);
+ const left = Math.min(
+ Math.max(viewportPadding, triggerRect.left),
+ window.innerWidth - preferredWidth - viewportPadding
+ );
+ const bottom = Math.max(
+ viewportPadding,
+ window.innerHeight - triggerRect.top + 10
+ );
+ const maxHeight = Math.max(viewportPadding, triggerRect.top - viewportPadding);
+
+ setPanelStyle({
+ bottom,
+ left,
+ width: preferredWidth,
+ maxHeight,
+ });
+ };
+
+ updatePosition();
+ window.addEventListener("resize", updatePosition);
+ window.addEventListener("scroll", updatePosition, true);
+
+ return () => {
+ window.removeEventListener("resize", updatePosition);
+ window.removeEventListener("scroll", updatePosition, true);
+ };
+ }, [isOpen, options]);
+
+ useEffect(() => {
+ if (!isOpen) {
+ return;
+ }
+
+ const handlePointerDown = (event: MouseEvent) => {
+ const target = event.target as Node;
+
+ if (
+ !containerRef.current?.contains(target) &&
+ !panelRef.current?.contains(target)
+ ) {
+ setIsOpen(false);
+ }
+ };
+
+ const handleEscape = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ setIsOpen(false);
+ }
+ };
+
+ document.addEventListener("mousedown", handlePointerDown);
+ window.addEventListener("keydown", handleEscape);
+
+ return () => {
+ document.removeEventListener("mousedown", handlePointerDown);
+ window.removeEventListener("keydown", handleEscape);
+ };
+ }, [isOpen]);
+
+ return (
+
+
+
+ {isOpen && panelStyle
+ ? createPortal(
+
+ {renderPanel ? (
+ renderPanel({
+ close: () => setIsOpen(false),
+ selectedOption,
+ })
+ ) : (
+ <>
+
+
+ {title ?? `Select ${label}`}
+
+
+
+
+ {options.map((option) => {
+ const isSelected = option.value === value;
+
+ return (
+
+ );
+ })}
+
+ >
+ )}
+
,
+ document.body
+ )
+ : null}
+
+ );
+}
diff --git a/components/studio/studio-canvas.tsx b/components/studio/studio-canvas.tsx
new file mode 100644
index 0000000..573a7d5
--- /dev/null
+++ b/components/studio/studio-canvas.tsx
@@ -0,0 +1,98 @@
+"use client";
+
+import { AnimatePresence, motion } from "framer-motion";
+import { useStudioStore } from "@/store/useStudioStore";
+import { GenerationDisplay } from "./generation-display";
+import { GenerationLoading } from "./generation-loading";
+import { StudioGalleryViewer } from "./studio-gallery-viewer";
+
+interface StudioCanvasProps {
+ isGenerating: boolean;
+ error: string | null;
+}
+
+export function StudioCanvas({ isGenerating, error }: StudioCanvasProps) {
+ const canvasAssets = useStudioStore((state) => state.canvasAssets);
+ const hasGeneration = isGenerating || canvasAssets.length > 0;
+ const canvasClassName = hasGeneration
+ ? "flex-1 overflow-auto px-4 pb-[200px] pt-4"
+ : "relative flex-1 overflow-auto px-4 pb-[200px] bg-[radial-gradient(circle_at_50%_34%,rgba(245,78,0,0.16),transparent_22%),radial-gradient(circle_at_38%_38%,rgba(36,162,255,0.12),transparent_20%),radial-gradient(circle_at_64%_62%,rgba(255,209,71,0.1),transparent_18%),linear-gradient(180deg,rgba(24,20,12,0.3)_0%,rgba(20,18,11,0.08)_46%,rgba(20,18,11,0.36)_100%)]";
+
+ return (
+
+ {!hasGeneration ? (
+ <>
+
+
+
+
+
+ >
+ ) : null}
+
+
+ {canvasAssets.length > 0 ? (
+
+
+ {isGenerating ? (
+
+
+
+ ) : null}
+
+ ) : isGenerating ? (
+
+ ) : error ? (
+
+ Generation failed
+ {error}
+
+ ) : (
+
+
+
+
+
+ Studio Playground
+
+
+ Build the scene you'd make if limits disappeared.
+
+
+ Start with a prompt, a frame, or a wild idea and shape it into
+ something cinematic.
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/components/studio/studio-gallery-viewer.tsx b/components/studio/studio-gallery-viewer.tsx
new file mode 100644
index 0000000..8ca10c9
--- /dev/null
+++ b/components/studio/studio-gallery-viewer.tsx
@@ -0,0 +1,182 @@
+"use client";
+
+import { AnimatePresence, motion } from "framer-motion";
+import Image from "next/image";
+import { useStudioGalleryViewer } from "@/hooks/use-studio-gallery-viewer";
+import { cn } from "@/lib/utils";
+
+function ViewerNavButton({
+ direction,
+ disabled,
+ onClick,
+}: {
+ direction: "previous" | "next";
+ disabled: boolean;
+ onClick: () => void;
+}) {
+ return (
+
+ );
+}
+
+export function StudioGalleryViewer() {
+ const {
+ assets,
+ selectedAsset,
+ selectedIndex,
+ isOpen,
+ hasPrevious,
+ hasNext,
+ closeViewer,
+ goToNext,
+ goToPrevious,
+ } = useStudioGalleryViewer();
+
+ return (
+
+ {isOpen && selectedAsset ? (
+
+ event.stopPropagation()}
+ >
+
+
+
+ Studio Viewer
+
+
+ {selectedIndex + 1} / {assets.length}
+
+
+
+
+
+
+
+
+
+
+
+
+ {selectedAsset.type === "video" ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ {selectedAsset.providerModel}
+
+
+
+
+
+
+
+
+ {selectedAsset.prompt}
+
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/components/studio/studio-prompt-bar.tsx b/components/studio/studio-prompt-bar.tsx
new file mode 100644
index 0000000..d1e56f5
--- /dev/null
+++ b/components/studio/studio-prompt-bar.tsx
@@ -0,0 +1,88 @@
+"use client";
+
+import { AnimatePresence, motion } from "framer-motion";
+import { useStudioStore } from "@/store/useStudioStore";
+import { ImageSettings } from "./image-settings";
+import { ModeSwitcher } from "./mode-switcher";
+import { PromptFooter } from "./prompt-footer";
+import { PromptInput } from "./prompt-input";
+import { VideoSettings } from "./video-settings";
+
+interface StudioPromptBarProps {
+ isGenerating: boolean;
+ onGenerate: () => void | Promise;
+}
+
+export function StudioPromptBar({
+ isGenerating,
+ onGenerate,
+}: StudioPromptBarProps) {
+ const { mode } = useStudioStore();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {mode === "image" ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/studio/video-settings.tsx b/components/studio/video-settings.tsx
new file mode 100644
index 0000000..bddbd3d
--- /dev/null
+++ b/components/studio/video-settings.tsx
@@ -0,0 +1,101 @@
+"use client";
+
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ ArtificialIntelligence01Icon,
+ AspectRatioIcon,
+ VolumeHighIcon,
+ VolumeOffIcon,
+} from "@hugeicons/core-free-icons";
+import { cn } from "@/lib/utils";
+import { useStudioStore } from "@/store/useStudioStore";
+import { DurationPicker } from "./duration-picker";
+import { SettingPicker, type SettingPickerOption } from "./setting-picker";
+import type { VideoAIModel, VideoAspectRatio } from "@/types/studio";
+
+const VIDEO_MODELS: SettingPickerOption[] = [
+ {
+ value: "kling-3",
+ label: "Kling 3",
+ description: "Balanced video generation for cinematic motion prompts.",
+ icon: ,
+ },
+];
+
+const ASPECT_RATIOS: SettingPickerOption[] = [
+ {
+ value: "16:9",
+ label: "16:9",
+ description: "Wide cinematic framing for landscape-first motion.",
+ icon: ,
+ },
+ {
+ value: "9:16",
+ label: "9:16",
+ description: "Vertical framing for reels, stories, and mobile video.",
+ icon: ,
+ },
+ {
+ value: "1:1",
+ label: "1:1",
+ description: "Square framing for balanced motion compositions.",
+ icon: ,
+ },
+];
+
+const settingChipClass =
+ "h-6.5 rounded-md border-transparent bg-foreground/10 px-2 text-[11px] font-medium text-foreground shadow-none outline-none focus:border-transparent focus:outline-none focus:ring-0 focus-visible:border-transparent focus-visible:outline-none focus-visible:ring-0 md:h-7 md:px-2.5 md:text-xs";
+
+export function VideoSettings() {
+ const {
+ videoAspectRatio,
+ setVideoAspectRatio,
+ videoDuration,
+ setVideoDuration,
+ videoAudio,
+ setVideoAudio,
+ videoModel,
+ setVideoModel,
+ } = useStudioStore();
+
+ return (
+
+ setVideoModel(value as VideoAIModel)}
+ className="w-[7.1rem] md:w-[9.75rem]"
+ />
+
+
+
+ setVideoAspectRatio(value as VideoAspectRatio)}
+ />
+
+
+
+ );
+}
diff --git a/components/ui/select.tsx b/components/ui/select.tsx
new file mode 100644
index 0000000..c0dc712
--- /dev/null
+++ b/components/ui/select.tsx
@@ -0,0 +1,190 @@
+"use client"
+
+import * as React from "react"
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
+import { Select as SelectPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Select({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectGroup({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectValue({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: React.ComponentProps & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ position = "item-aligned",
+ align = "center",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/components/ui/slider.tsx b/components/ui/slider.tsx
new file mode 100644
index 0000000..46ebc4b
--- /dev/null
+++ b/components/ui/slider.tsx
@@ -0,0 +1,63 @@
+"use client"
+
+import * as React from "react"
+import { Slider as SliderPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Slider({
+ className,
+ defaultValue,
+ value,
+ min = 0,
+ max = 100,
+ ...props
+}: React.ComponentProps) {
+ const _values = React.useMemo(
+ () =>
+ Array.isArray(value)
+ ? value
+ : Array.isArray(defaultValue)
+ ? defaultValue
+ : [min, max],
+ [value, defaultValue, min, max]
+ )
+
+ return (
+
+
+
+
+ {Array.from({ length: _values.length }, (_, index) => (
+
+ ))}
+
+ )
+}
+
+export { Slider }
diff --git a/components/ui/textarea.tsx b/components/ui/textarea.tsx
new file mode 100644
index 0000000..e67d8fe
--- /dev/null
+++ b/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/components/ui/toggle-group.tsx b/components/ui/toggle-group.tsx
new file mode 100644
index 0000000..9894607
--- /dev/null
+++ b/components/ui/toggle-group.tsx
@@ -0,0 +1,83 @@
+"use client"
+
+import * as React from "react"
+import { type VariantProps } from "class-variance-authority"
+import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+import { toggleVariants } from "@/components/ui/toggle"
+
+const ToggleGroupContext = React.createContext<
+ VariantProps & {
+ spacing?: number
+ }
+>({
+ size: "default",
+ variant: "default",
+ spacing: 0,
+})
+
+function ToggleGroup({
+ className,
+ variant,
+ size,
+ spacing = 0,
+ children,
+ ...props
+}: React.ComponentProps &
+ VariantProps & {
+ spacing?: number
+ }) {
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+function ToggleGroupItem({
+ className,
+ children,
+ variant,
+ size,
+ ...props
+}: React.ComponentProps &
+ VariantProps) {
+ const context = React.useContext(ToggleGroupContext)
+
+ return (
+
+ {children}
+
+ )
+}
+
+export { ToggleGroup, ToggleGroupItem }
diff --git a/components/ui/toggle.tsx b/components/ui/toggle.tsx
new file mode 100644
index 0000000..5250c98
--- /dev/null
+++ b/components/ui/toggle.tsx
@@ -0,0 +1,47 @@
+"use client"
+
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { Toggle as TogglePrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+const toggleVariants = cva(
+ "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ variant: {
+ default: "bg-transparent",
+ outline:
+ "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
+ },
+ size: {
+ default: "h-9 min-w-9 px-2",
+ sm: "h-8 min-w-8 px-1.5",
+ lg: "h-10 min-w-10 px-2.5",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function Toggle({
+ className,
+ variant,
+ size,
+ ...props
+}: React.ComponentProps &
+ VariantProps) {
+ return (
+
+ )
+}
+
+export { Toggle, toggleVariants }
diff --git a/hooks/use-generate-image.ts b/hooks/use-generate-image.ts
new file mode 100644
index 0000000..28dab8a
--- /dev/null
+++ b/hooks/use-generate-image.ts
@@ -0,0 +1,146 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import type { GenerationJobResponse } from "@/types/studio";
+
+interface GenerateImageInput {
+ prompt: string;
+ aspectRatio: string;
+ count: number;
+ resolution: string;
+ attachments: File[];
+}
+
+async function parseGenerationResponse(response: Response) {
+ const payload = (await response.json()) as GenerationJobResponse;
+
+ if (!response.ok || payload.status === "failed") {
+ throw new Error(payload.error || "Image generation failed.");
+ }
+
+ return payload;
+}
+
+function buildStatusPayload(response: GenerationJobResponse) {
+ return {
+ requestId: response.jobId,
+ endpointId: response.providerModel,
+ type: response.type,
+ prompt: response.prompt,
+ requestSettings: JSON.stringify(response.requestSettings),
+ };
+}
+
+export function useGenerateImage() {
+ const [data, setData] = useState(null);
+ const [error, setError] = useState(null);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [isProcessing, setIsProcessing] = useState(false);
+ const pollTimeoutRef = useRef(null);
+
+ const clearPolling = useCallback(() => {
+ if (pollTimeoutRef.current !== null) {
+ window.clearTimeout(pollTimeoutRef.current);
+ pollTimeoutRef.current = null;
+ }
+ }, []);
+
+ useEffect(() => clearPolling, [clearPolling]);
+
+ const poll = useCallback(
+ async (pendingResponse: GenerationJobResponse) => {
+ setIsProcessing(true);
+
+ try {
+ const response = await fetch("/api/studio/generate/status", {
+ method: "POST",
+ cache: "no-store",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(buildStatusPayload(pendingResponse)),
+ });
+ const payload = await parseGenerationResponse(response);
+
+ setData(payload);
+
+ if (payload.status === "queued" || payload.status === "processing") {
+ pollTimeoutRef.current = window.setTimeout(() => {
+ void poll(payload);
+ }, 1500);
+ return;
+ }
+
+ setIsProcessing(false);
+ } catch (caughtError) {
+ setError(
+ caughtError instanceof Error
+ ? caughtError.message
+ : "Image generation failed."
+ );
+ setIsProcessing(false);
+ }
+ },
+ []
+ );
+
+ const mutateAsync = useCallback(
+ async (input: GenerateImageInput) => {
+ clearPolling();
+ setError(null);
+ setData(null);
+ setIsSubmitting(true);
+
+ const formData = new FormData();
+ formData.set("prompt", input.prompt);
+ formData.set("aspectRatio", input.aspectRatio);
+ formData.set("count", String(input.count));
+ formData.set("resolution", input.resolution);
+ input.attachments.forEach((attachment) => {
+ formData.append("attachments", attachment);
+ });
+
+ try {
+ const response = await fetch("/api/studio/generate/image", {
+ method: "POST",
+ body: formData,
+ });
+ const payload = await parseGenerationResponse(response);
+
+ setData(payload);
+ setIsSubmitting(false);
+ await poll(payload);
+ return payload;
+ } catch (caughtError) {
+ const message =
+ caughtError instanceof Error
+ ? caughtError.message
+ : "Image generation failed.";
+ setError(message);
+ setIsSubmitting(false);
+ setIsProcessing(false);
+ throw caughtError;
+ }
+ },
+ [clearPolling, poll]
+ );
+
+ return {
+ mutate: (input: GenerateImageInput) => {
+ void mutateAsync(input);
+ },
+ mutateAsync,
+ data,
+ error,
+ isSubmitting,
+ isProcessing,
+ isPending: isSubmitting || isProcessing,
+ reset: () => {
+ clearPolling();
+ setData(null);
+ setError(null);
+ setIsSubmitting(false);
+ setIsProcessing(false);
+ },
+ };
+}
diff --git a/hooks/use-generate-video.ts b/hooks/use-generate-video.ts
new file mode 100644
index 0000000..0a00347
--- /dev/null
+++ b/hooks/use-generate-video.ts
@@ -0,0 +1,149 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import type { GenerationJobResponse } from "@/types/studio";
+
+interface GenerateVideoInput {
+ prompt: string;
+ aspectRatio: string;
+ duration: number;
+ audio: boolean;
+ firstFrame: File;
+ lastFrame: File | null;
+}
+
+async function parseGenerationResponse(response: Response) {
+ const payload = (await response.json()) as GenerationJobResponse;
+
+ if (!response.ok || payload.status === "failed") {
+ throw new Error(payload.error || "Video generation failed.");
+ }
+
+ return payload;
+}
+
+function buildStatusPayload(response: GenerationJobResponse) {
+ return {
+ requestId: response.jobId,
+ endpointId: response.providerModel,
+ type: response.type,
+ prompt: response.prompt,
+ requestSettings: JSON.stringify(response.requestSettings),
+ };
+}
+
+export function useGenerateVideo() {
+ const [data, setData] = useState(null);
+ const [error, setError] = useState(null);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [isProcessing, setIsProcessing] = useState(false);
+ const pollTimeoutRef = useRef(null);
+
+ const clearPolling = useCallback(() => {
+ if (pollTimeoutRef.current !== null) {
+ window.clearTimeout(pollTimeoutRef.current);
+ pollTimeoutRef.current = null;
+ }
+ }, []);
+
+ useEffect(() => clearPolling, [clearPolling]);
+
+ const poll = useCallback(
+ async (pendingResponse: GenerationJobResponse) => {
+ setIsProcessing(true);
+
+ try {
+ const response = await fetch("/api/studio/generate/status", {
+ method: "POST",
+ cache: "no-store",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(buildStatusPayload(pendingResponse)),
+ });
+ const payload = await parseGenerationResponse(response);
+
+ setData(payload);
+
+ if (payload.status === "queued" || payload.status === "processing") {
+ pollTimeoutRef.current = window.setTimeout(() => {
+ void poll(payload);
+ }, 2000);
+ return;
+ }
+
+ setIsProcessing(false);
+ } catch (caughtError) {
+ setError(
+ caughtError instanceof Error
+ ? caughtError.message
+ : "Video generation failed."
+ );
+ setIsProcessing(false);
+ }
+ },
+ []
+ );
+
+ const mutateAsync = useCallback(
+ async (input: GenerateVideoInput) => {
+ clearPolling();
+ setError(null);
+ setData(null);
+ setIsSubmitting(true);
+
+ const formData = new FormData();
+ formData.set("prompt", input.prompt);
+ formData.set("aspectRatio", input.aspectRatio);
+ formData.set("duration", String(input.duration));
+ formData.set("audio", String(input.audio));
+ formData.set("firstFrame", input.firstFrame);
+
+ if (input.lastFrame) {
+ formData.set("lastFrame", input.lastFrame);
+ }
+
+ try {
+ const response = await fetch("/api/studio/generate/video", {
+ method: "POST",
+ body: formData,
+ });
+ const payload = await parseGenerationResponse(response);
+
+ setData(payload);
+ setIsSubmitting(false);
+ await poll(payload);
+ return payload;
+ } catch (caughtError) {
+ const message =
+ caughtError instanceof Error
+ ? caughtError.message
+ : "Video generation failed.";
+ setError(message);
+ setIsSubmitting(false);
+ setIsProcessing(false);
+ throw caughtError;
+ }
+ },
+ [clearPolling, poll]
+ );
+
+ return {
+ mutate: (input: GenerateVideoInput) => {
+ void mutateAsync(input);
+ },
+ mutateAsync,
+ data,
+ error,
+ isSubmitting,
+ isProcessing,
+ isPending: isSubmitting || isProcessing,
+ reset: () => {
+ clearPolling();
+ setData(null);
+ setError(null);
+ setIsSubmitting(false);
+ setIsProcessing(false);
+ },
+ };
+}
diff --git a/hooks/use-studio-gallery-viewer.ts b/hooks/use-studio-gallery-viewer.ts
new file mode 100644
index 0000000..62d0361
--- /dev/null
+++ b/hooks/use-studio-gallery-viewer.ts
@@ -0,0 +1,72 @@
+"use client";
+
+import { useEffect, useMemo, useRef } from "react";
+import { useStudioStore } from "@/store/useStudioStore";
+
+export function useStudioGalleryViewer() {
+ const canvasAssets = useStudioStore((state) => state.canvasAssets);
+ const selectedAssetId = useStudioStore((state) => state.selectedAssetId);
+ const closeViewer = useStudioStore((state) => state.closeViewer);
+ const goToNext = useStudioStore((state) => state.goToNext);
+ const goToPrevious = useStudioStore((state) => state.goToPrevious);
+
+ const selectedIndex = useMemo(
+ () =>
+ selectedAssetId
+ ? canvasAssets.findIndex((asset) => asset.id === selectedAssetId)
+ : -1,
+ [canvasAssets, selectedAssetId]
+ );
+
+ const selectedAsset =
+ selectedIndex >= 0 ? canvasAssets[selectedIndex] : null;
+ const isOpen = selectedIndex >= 0;
+ const hasPrevious = selectedIndex > 0;
+ const hasNext = selectedIndex >= 0 && selectedIndex < canvasAssets.length - 1;
+ const onKeyDownRef = useRef<(event: KeyboardEvent) => void>(() => {});
+
+ onKeyDownRef.current = (event: KeyboardEvent) => {
+ if (!isOpen) {
+ return;
+ }
+
+ if (event.key === "Escape") {
+ closeViewer();
+ return;
+ }
+
+ if (event.key === "ArrowLeft" && hasPrevious) {
+ goToPrevious();
+ return;
+ }
+
+ if (event.key === "ArrowRight" && hasNext) {
+ goToNext();
+ }
+ };
+
+ useEffect(() => {
+ if (!isOpen) {
+ return;
+ }
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ onKeyDownRef.current(event);
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [isOpen]);
+
+ return {
+ assets: canvasAssets,
+ selectedAsset,
+ selectedIndex,
+ isOpen,
+ hasPrevious,
+ hasNext,
+ closeViewer,
+ goToNext,
+ goToPrevious,
+ };
+}
diff --git a/hooks/use-studio-generation.ts b/hooks/use-studio-generation.ts
new file mode 100644
index 0000000..28f1813
--- /dev/null
+++ b/hooks/use-studio-generation.ts
@@ -0,0 +1,163 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { useGenerateImage } from "@/hooks/use-generate-image";
+import { useGenerateVideo } from "@/hooks/use-generate-video";
+import { useStudioStore } from "@/store/useStudioStore";
+import type {
+ GenerationJobResponse,
+ GenerationResult,
+ StudioCanvasAsset,
+ StudioMode,
+ StudioGenerationState,
+} from "@/types/studio";
+
+function toGenerationResult(
+ response: GenerationJobResponse | null
+): GenerationResult | null {
+ if (!response || response.status !== "completed") {
+ return null;
+ }
+
+ return {
+ jobId: response.jobId,
+ status: "success",
+ type: response.type,
+ urls: response.outputs.map((output) => output.url),
+ outputs: response.outputs,
+ prompt: response.prompt,
+ providerModel: response.providerModel,
+ settings: response.requestSettings,
+ createdAt: response.createdAt,
+ };
+}
+
+function resultToCanvasAssets(
+ result: GenerationResult
+): StudioCanvasAsset[] {
+ return result.outputs.map((output, index) => ({
+ id: `${result.jobId}-${index}`,
+ jobId: result.jobId,
+ outputIndex: index,
+ type: result.type,
+ url: output.url,
+ prompt: result.prompt,
+ providerModel: result.providerModel,
+ createdAt: result.createdAt,
+ contentType: output.contentType,
+ fileName: output.fileName,
+ aspectRatio: result.settings.aspectRatio,
+ }));
+}
+
+export function useStudioGeneration() {
+ const imageGeneration = useGenerateImage();
+ const videoGeneration = useGenerateVideo();
+ const studioState = useStudioStore();
+ const [validationError, setValidationError] = useState(null);
+ const [canvasMode, setCanvasMode] = useState(null);
+
+ const canvasGeneration =
+ canvasMode === "image"
+ ? imageGeneration
+ : canvasMode === "video"
+ ? videoGeneration
+ : null;
+
+ const state = useMemo(() => {
+ const data = canvasGeneration?.data ?? null;
+ const error = validationError ?? canvasGeneration?.error ?? null;
+ const result = toGenerationResult(data);
+ const isSubmitting = canvasGeneration?.isSubmitting ?? false;
+ const isProcessing = canvasGeneration?.isProcessing ?? false;
+
+ return {
+ status: error
+ ? "error"
+ : result
+ ? "success"
+ : isSubmitting
+ ? "submitting"
+ : isProcessing
+ ? "processing"
+ : "idle",
+ isSubmitting,
+ isProcessing,
+ isGenerating: isSubmitting || isProcessing,
+ result,
+ error,
+ };
+ }, [canvasGeneration, validationError]);
+
+ useEffect(() => {
+ if (!state.result) {
+ return;
+ }
+
+ const hasJobAssets = studioState.canvasAssets.some(
+ (asset) => asset.jobId === state.result?.jobId
+ );
+
+ if (!hasJobAssets) {
+ studioState.appendCanvasAssets(resultToCanvasAssets(state.result));
+ }
+ }, [state.result, studioState]);
+
+ const generate = useCallback(async () => {
+ try {
+ const prompt = studioState.prompt.trim();
+
+ if (!prompt || state.isGenerating) {
+ return;
+ }
+
+ setValidationError(null);
+
+ if (studioState.mode === "image") {
+ const attachments = studioState.attachments.map(
+ (attachment) => attachment.file
+ );
+
+ setCanvasMode("image");
+ studioState.setPrompt("");
+ studioState.clearAttachments();
+ await imageGeneration.mutateAsync({
+ prompt,
+ aspectRatio: studioState.imageAspectRatio,
+ count: studioState.imageCount,
+ resolution: studioState.imageResolution,
+ attachments,
+ });
+ return;
+ }
+
+ if (!studioState.firstFrame) {
+ setValidationError("Upload a first frame before generating video.");
+ return;
+ }
+
+ const firstFrame = studioState.firstFrame;
+ const lastFrame = studioState.lastFrame;
+
+ setCanvasMode("video");
+ studioState.setPrompt("");
+ studioState.setFirstFrame(null);
+ studioState.setLastFrame(null);
+ await videoGeneration.mutateAsync({
+ prompt,
+ aspectRatio: studioState.videoAspectRatio,
+ duration: studioState.videoDuration,
+ audio: studioState.videoAudio,
+ firstFrame,
+ lastFrame,
+ });
+ } catch {
+ return;
+ }
+ }, [imageGeneration, state.isGenerating, studioState, videoGeneration]);
+
+ return {
+ generate,
+ state,
+ };
+}
diff --git a/lib/fal/studio.ts b/lib/fal/studio.ts
new file mode 100644
index 0000000..f232e4e
--- /dev/null
+++ b/lib/fal/studio.ts
@@ -0,0 +1,322 @@
+import { fal } from "@fal-ai/client";
+import type { QueueStatus } from "@fal-ai/client";
+import type {
+ GeneratedMediaOutput,
+ GenerationJobResponse,
+ ImageAspectRatio,
+ ImageGenerationSettings,
+ ImageResolution,
+ ProviderJobStatus,
+ VideoAspectRatio,
+ VideoGenerationSettings,
+} from "@/types/studio";
+
+const FAL_IMAGE_ENDPOINT = "fal-ai/nano-banana-2";
+const FAL_IMAGE_EDIT_ENDPOINT = "fal-ai/nano-banana-2/edit";
+const FAL_VIDEO_ENDPOINT = "fal-ai/kling-video/v3/pro/image-to-video";
+const FAL_VIDEO_QUEUE_ENDPOINT = "fal-ai/kling-video/v3/pro";
+
+type FalFileOutput = {
+ url?: string;
+ content_type?: string;
+ file_name?: string;
+};
+
+type ImageGenerationInput = {
+ prompt: string;
+ aspectRatio: ImageAspectRatio;
+ count: number;
+ resolution: ImageResolution;
+ attachments: File[];
+};
+
+type VideoGenerationInput = {
+ prompt: string;
+ aspectRatio: VideoAspectRatio;
+ duration: number;
+ audio: boolean;
+ firstFrame: File;
+ lastFrame: File | null;
+};
+
+function ensureFalConfigured() {
+ const falKey = process.env.FAL_KEY;
+
+ if (!falKey) {
+ throw new Error("Missing FAL_KEY environment variable.");
+ }
+
+ fal.config({
+ credentials: falKey,
+ });
+}
+
+function mapProviderStatus(status: QueueStatus["status"]): ProviderJobStatus {
+ if (status === "IN_QUEUE") {
+ return "queued";
+ }
+
+ if (status === "IN_PROGRESS") {
+ return "processing";
+ }
+
+ return "completed";
+}
+
+function normalizeOutputs(files: FalFileOutput[] | undefined): GeneratedMediaOutput[] {
+ if (!Array.isArray(files)) {
+ return [];
+ }
+
+ return files
+ .filter((file): file is FalFileOutput & { url: string } => Boolean(file?.url))
+ .map((file) => ({
+ url: file.url,
+ contentType: file.content_type,
+ fileName: file.file_name,
+ }));
+}
+
+function toQueueEndpointId(endpointId: string) {
+ if (endpointId === FAL_IMAGE_EDIT_ENDPOINT) {
+ return FAL_IMAGE_ENDPOINT;
+ }
+
+ if (endpointId === FAL_VIDEO_ENDPOINT) {
+ return FAL_VIDEO_QUEUE_ENDPOINT;
+ }
+
+ return endpointId;
+}
+
+function normalizeImageResponse(
+ requestId: string,
+ prompt: string,
+ settings: ImageGenerationSettings,
+ data: unknown
+): GenerationJobResponse {
+ const images = normalizeOutputs((data as { images?: FalFileOutput[] })?.images);
+
+ return {
+ jobId: requestId,
+ status: "completed",
+ type: "image",
+ outputs: images,
+ prompt,
+ providerModel: settings.isEdit ? FAL_IMAGE_EDIT_ENDPOINT : FAL_IMAGE_ENDPOINT,
+ requestSettings: settings,
+ createdAt: new Date().toISOString(),
+ };
+}
+
+function normalizeVideoResponse(
+ requestId: string,
+ prompt: string,
+ settings: VideoGenerationSettings,
+ data: unknown
+): GenerationJobResponse {
+ const outputs = normalizeOutputs(
+ (data as { video?: FalFileOutput })?.video
+ ? [(data as { video: FalFileOutput }).video]
+ : undefined
+ );
+
+ return {
+ jobId: requestId,
+ status: "completed",
+ type: "video",
+ outputs,
+ prompt,
+ providerModel: FAL_VIDEO_ENDPOINT,
+ requestSettings: settings,
+ createdAt: new Date().toISOString(),
+ };
+}
+
+function createImageSettings(input: ImageGenerationInput): ImageGenerationSettings {
+ return {
+ aspectRatio: input.aspectRatio,
+ model: "nano-banana-2",
+ count: input.count,
+ resolution: input.resolution,
+ isEdit: input.attachments.length > 0,
+ attachmentCount: input.attachments.length,
+ };
+}
+
+function createVideoSettings(
+ input: VideoGenerationInput,
+ firstFrameUrl: string,
+ lastFrameUrl?: string
+): VideoGenerationSettings {
+ return {
+ aspectRatio: input.aspectRatio,
+ duration: input.duration,
+ model: "kling-3",
+ audio: input.audio,
+ firstFrame: firstFrameUrl,
+ lastFrame: lastFrameUrl,
+ };
+}
+
+function resolutionToFalValue(resolution: ImageResolution) {
+ switch (resolution) {
+ case "1024":
+ return "1K";
+ case "2048":
+ return "2K";
+ case "4096":
+ return "4K";
+ default:
+ return "2K";
+ }
+}
+
+export async function submitImageGeneration(input: ImageGenerationInput) {
+ ensureFalConfigured();
+
+ const attachmentUrls = await Promise.all(
+ input.attachments.map((attachment) => fal.storage.upload(attachment))
+ );
+
+ const settings = createImageSettings(input);
+ const endpointId =
+ attachmentUrls.length > 0 ? FAL_IMAGE_EDIT_ENDPOINT : FAL_IMAGE_ENDPOINT;
+
+ const queue = await fal.queue.submit(endpointId, {
+ input: {
+ prompt: input.prompt,
+ aspect_ratio: input.aspectRatio,
+ num_images: input.count,
+ resolution: resolutionToFalValue(input.resolution),
+ ...(attachmentUrls.length > 0 ? { image_urls: attachmentUrls } : {}),
+ },
+ });
+
+ return {
+ requestId: queue.request_id,
+ endpointId,
+ response: {
+ jobId: queue.request_id,
+ status: "queued" as const,
+ type: "image" as const,
+ outputs: [],
+ prompt: input.prompt,
+ providerModel: endpointId,
+ requestSettings: settings,
+ createdAt: new Date().toISOString(),
+ },
+ };
+}
+
+export async function submitVideoGeneration(input: VideoGenerationInput) {
+ ensureFalConfigured();
+
+ const startImageUrl = await fal.storage.upload(input.firstFrame);
+ const endImageUrl = input.lastFrame
+ ? await fal.storage.upload(input.lastFrame)
+ : undefined;
+ const settings = createVideoSettings(input, startImageUrl, endImageUrl);
+
+ const queue = await fal.queue.submit(FAL_VIDEO_ENDPOINT, {
+ input: {
+ prompt: input.prompt,
+ start_image_url: startImageUrl,
+ duration: String(input.duration),
+ generate_audio: input.audio,
+ aspect_ratio: input.aspectRatio,
+ ...(endImageUrl ? { end_image_url: endImageUrl } : {}),
+ },
+ });
+
+ return {
+ requestId: queue.request_id,
+ endpointId: FAL_VIDEO_ENDPOINT,
+ response: {
+ jobId: queue.request_id,
+ status: "queued" as const,
+ type: "video" as const,
+ outputs: [],
+ prompt: input.prompt,
+ providerModel: FAL_VIDEO_ENDPOINT,
+ requestSettings: settings,
+ createdAt: new Date().toISOString(),
+ },
+ };
+}
+
+export async function getGenerationStatus(
+ endpointId: string,
+ requestId: string,
+ prompt: string,
+ type: "image" | "video",
+ settings: ImageGenerationSettings | VideoGenerationSettings
+): Promise {
+ ensureFalConfigured();
+ const queueEndpointId = toQueueEndpointId(endpointId);
+
+ const status = await fal.queue.status(queueEndpointId, {
+ requestId,
+ logs: true,
+ });
+
+ if (status.status !== "COMPLETED") {
+ return {
+ jobId: requestId,
+ status: mapProviderStatus(status.status),
+ type,
+ outputs: [],
+ prompt,
+ providerModel: endpointId,
+ requestSettings: settings,
+ createdAt: new Date().toISOString(),
+ };
+ }
+
+ const result = await fal.queue.result(queueEndpointId, {
+ requestId,
+ });
+
+ if (type === "image") {
+ return normalizeImageResponse(
+ result.requestId,
+ prompt,
+ settings as ImageGenerationSettings,
+ result.data
+ );
+ }
+
+ return normalizeVideoResponse(
+ result.requestId,
+ prompt,
+ settings as VideoGenerationSettings,
+ result.data
+ );
+}
+
+export function createGenerationErrorResponse(
+ type: "image" | "video",
+ prompt: string,
+ providerModel: string,
+ requestSettings: ImageGenerationSettings | VideoGenerationSettings,
+ error: unknown,
+ requestId = crypto.randomUUID()
+): GenerationJobResponse {
+ return {
+ jobId: requestId,
+ status: "failed",
+ type,
+ outputs: [],
+ prompt,
+ providerModel,
+ requestSettings,
+ createdAt: new Date().toISOString(),
+ error: error instanceof Error ? error.message : "Generation failed.",
+ };
+}
+
+export const FAL_STUDIO_ENDPOINTS = {
+ image: FAL_IMAGE_ENDPOINT,
+ imageEdit: FAL_IMAGE_EDIT_ENDPOINT,
+ video: FAL_VIDEO_ENDPOINT,
+} as const;
diff --git a/lib/studio-drag.ts b/lib/studio-drag.ts
new file mode 100644
index 0000000..df98e48
--- /dev/null
+++ b/lib/studio-drag.ts
@@ -0,0 +1,76 @@
+"use client";
+
+const GENERATED_ASSET_MIME = "application/x-thinkingsoundlab-generated-asset";
+
+export interface DraggedStudioAsset {
+ url: string;
+ fileName?: string;
+ contentType?: string;
+}
+
+function inferFileName(url: string, providedName?: string, contentType?: string) {
+ if (providedName) {
+ return providedName;
+ }
+
+ const pathname = (() => {
+ try {
+ return new URL(url).pathname;
+ } catch {
+ return "";
+ }
+ })();
+ const lastSegment = pathname.split("/").filter(Boolean).pop();
+
+ if (lastSegment) {
+ return lastSegment;
+ }
+
+ const extension = contentType?.split("/")[1]?.split(";")[0] ?? "png";
+ return `generated-image.${extension}`;
+}
+
+export function setDraggedStudioAsset(
+ dataTransfer: DataTransfer,
+ asset: DraggedStudioAsset
+) {
+ const payload = JSON.stringify(asset);
+ dataTransfer.effectAllowed = "copy";
+ dataTransfer.setData(GENERATED_ASSET_MIME, payload);
+ dataTransfer.setData("text/plain", asset.url);
+ dataTransfer.setData("text/uri-list", asset.url);
+}
+
+export function hasDraggedStudioAsset(dataTransfer: DataTransfer) {
+ return Array.from(dataTransfer.types).includes(GENERATED_ASSET_MIME);
+}
+
+export function readDraggedStudioAsset(
+ dataTransfer: DataTransfer
+): DraggedStudioAsset | null {
+ const payload = dataTransfer.getData(GENERATED_ASSET_MIME);
+
+ if (!payload) {
+ return null;
+ }
+
+ try {
+ return JSON.parse(payload) as DraggedStudioAsset;
+ } catch {
+ return null;
+ }
+}
+
+export async function draggedStudioAssetToFile(asset: DraggedStudioAsset) {
+ const response = await fetch(asset.url);
+
+ if (!response.ok) {
+ throw new Error("Failed to fetch generated image.");
+ }
+
+ const blob = await response.blob();
+ const type = blob.type || asset.contentType || "image/png";
+ const fileName = inferFileName(asset.url, asset.fileName, type);
+
+ return new File([blob], fileName, { type });
+}
diff --git a/package-lock.json b/package-lock.json
index b66b083..c1ca6ae 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,9 @@
"name": "thinkingsoundlabwebsite",
"version": "0.1.0",
"dependencies": {
+ "@fal-ai/client": "^1.9.5",
+ "@hugeicons/core-free-icons": "^4.1.1",
+ "@hugeicons/react": "^1.1.6",
"@radix-ui/react-slot": "^1.2.3",
"@tailwindcss/typography": "^0.5.19",
"@vercel/analytics": "^1.5.0",
@@ -22,6 +25,7 @@
"next": "15.5.7",
"next-themes": "^0.4.6",
"posthog-js": "^1.359.1",
+ "radix-ui": "^1.4.3",
"react": "19.1.0",
"react-dom": "19.1.0",
"rehype-slug": "^6.0.0",
@@ -219,6 +223,73 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@fal-ai/client": {
+ "version": "1.9.5",
+ "resolved": "https://registry.npmjs.org/@fal-ai/client/-/client-1.9.5.tgz",
+ "integrity": "sha512-knCMOqXapzL5Lsp4Xh/B/VfvbseKgHg2Kt//MjcxN5weF59/26En3zXTPd8pljl4QAr7b62X5EuNCT69MpyjSA==",
+ "license": "MIT",
+ "dependencies": {
+ "@msgpack/msgpack": "^3.0.0-beta2",
+ "eventsource-parser": "^1.1.2",
+ "robot3": "^0.4.1"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@floating-ui/core": {
+ "version": "1.7.5",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
+ "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/utils": "^0.2.11"
+ }
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "1.7.6",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
+ "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/core": "^1.7.5",
+ "@floating-ui/utils": "^0.2.11"
+ }
+ },
+ "node_modules/@floating-ui/react-dom": {
+ "version": "2.1.8",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
+ "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/dom": "^1.7.6"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.11",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
+ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
+ "license": "MIT"
+ },
+ "node_modules/@hugeicons/core-free-icons": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-4.1.1.tgz",
+ "integrity": "sha512-teqIBvPHl90ygIwKyJwTxOH8aNp1X1PjDTcMvLkEwdPxPD+8mssrZ5kXKIAJJFYPsz69a8LYQY0UPid4PAdavg==",
+ "license": "MIT"
+ },
+ "node_modules/@hugeicons/react": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@hugeicons/react/-/react-1.1.6.tgz",
+ "integrity": "sha512-c2LhXJMAW5wN1pC/smBXG0YPqUON6ceR/ZdXHCjEI9KvB+hjtqYjmzIxok5hAQOeXGz0WtORgCQMzqewFKAZwg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">=16.0.0"
+ }
+ },
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -729,6 +800,15 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@msgpack/msgpack": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz",
+ "integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 18"
+ }
+ },
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@@ -1153,101 +1233,1554 @@
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
- "@opentelemetry/api": ">=1.3.0 <1.10.0"
+ "@opentelemetry/api": ">=1.3.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/semantic-conventions": {
+ "version": "1.40.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz",
+ "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@posthog/core": {
+ "version": "1.23.2",
+ "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.23.2.tgz",
+ "integrity": "sha512-zTDdda9NuSHrnwSOfFMxX/pyXiycF4jtU1kTr8DL61dHhV+7LF6XF1ndRZZTuaGGbfbb/GJYkEsjEX9SXfNZeQ==",
+ "license": "MIT",
+ "dependencies": {
+ "cross-spawn": "^7.0.6"
+ }
+ },
+ "node_modules/@posthog/types": {
+ "version": "1.359.1",
+ "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.359.1.tgz",
+ "integrity": "sha512-oQihoHWLnOkSkzOToCWKNigbJ7UZcIkl+rSJuq2PLwL7EB0Q/r1UGSbVCkrPH8xtPbYpi7w4TVpMrg41TMT+LQ==",
+ "license": "MIT"
+ },
+ "node_modules/@protobufjs/aspromise": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
+ "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/base64": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
+ "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/codegen": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
+ "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/eventemitter": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
+ "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/fetch": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
+ "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.1",
+ "@protobufjs/inquire": "^1.1.0"
+ }
+ },
+ "node_modules/@protobufjs/float": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
+ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/inquire": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
+ "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/path": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
+ "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/pool": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
+ "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/utf8": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
+ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@radix-ui/number": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
+ "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/primitive": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
+ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/react-accessible-icon": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.7.tgz",
+ "integrity": "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-visually-hidden": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-accordion": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz",
+ "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collapsible": "1.1.12",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-alert-dialog": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
+ "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dialog": "1.1.15",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-arrow": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
+ "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-aspect-ratio": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz",
+ "integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-avatar": {
+ "version": "1.1.10",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz",
+ "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-is-hydrated": "0.1.0",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-checkbox": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
+ "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-previous": "1.1.1",
+ "@radix-ui/react-use-size": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-collapsible": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
+ "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-collection": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
+ "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-compose-refs": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
+ "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-context": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
+ "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-context-menu": {
+ "version": "2.2.16",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz",
+ "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-menu": "2.1.16",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-controllable-state": "1.2.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
+ "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-direction": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
+ "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dismissable-layer": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
+ "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-escape-keydown": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dropdown-menu": {
+ "version": "2.1.16",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz",
+ "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-menu": "2.1.16",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-focus-guards": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
+ "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-focus-scope": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
+ "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-form": {
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.8.tgz",
+ "integrity": "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-label": "2.1.7",
+ "@radix-ui/react-primitive": "2.1.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-hover-card": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz",
+ "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-popper": "1.2.8",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-id": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
+ "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-label": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz",
+ "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-menu": {
+ "version": "2.1.16",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
+ "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-popper": "1.2.8",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-roving-focus": "1.1.11",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-menubar": {
+ "version": "1.1.16",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz",
+ "integrity": "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-menu": "2.1.16",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-roving-focus": "1.1.11",
+ "@radix-ui/react-use-controllable-state": "1.2.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-navigation-menu": {
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz",
+ "integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1",
+ "@radix-ui/react-use-previous": "1.1.1",
+ "@radix-ui/react-visually-hidden": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-one-time-password-field": {
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.8.tgz",
+ "integrity": "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/number": "1.1.1",
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-roving-focus": "1.1.11",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-effect-event": "0.0.2",
+ "@radix-ui/react-use-is-hydrated": "0.1.0",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-password-toggle-field": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.3.tgz",
+ "integrity": "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-effect-event": "0.0.2",
+ "@radix-ui/react-use-is-hydrated": "0.1.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-popover": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
+ "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-popper": "1.2.8",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-popper": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
+ "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/react-dom": "^2.0.0",
+ "@radix-ui/react-arrow": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-layout-effect": "1.1.1",
+ "@radix-ui/react-use-rect": "1.1.1",
+ "@radix-ui/react-use-size": "1.1.1",
+ "@radix-ui/rect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-portal": {
+ "version": "1.1.9",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
+ "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-presence": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
+ "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
+ "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-progress": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz",
+ "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-radio-group": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz",
+ "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-roving-focus": "1.1.11",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-previous": "1.1.1",
+ "@radix-ui/react-use-size": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-roving-focus": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
+ "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-controllable-state": "1.2.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-scroll-area": {
+ "version": "1.2.10",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz",
+ "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/number": "1.1.1",
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-select": {
+ "version": "2.2.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
+ "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/number": "1.1.1",
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-popper": "1.2.8",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1",
+ "@radix-ui/react-use-previous": "1.1.1",
+ "@radix-ui/react-visually-hidden": "1.2.3",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-separator": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
+ "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-slider": {
+ "version": "1.3.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz",
+ "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/number": "1.1.1",
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1",
+ "@radix-ui/react-use-previous": "1.1.1",
+ "@radix-ui/react-use-size": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-switch": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
+ "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-previous": "1.1.1",
+ "@radix-ui/react-use-size": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tabs": {
+ "version": "1.1.13",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
+ "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-roving-focus": "1.1.11",
+ "@radix-ui/react-use-controllable-state": "1.2.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-toast": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz",
+ "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1",
+ "@radix-ui/react-visually-hidden": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-toggle": {
+ "version": "1.1.10",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
+ "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-toggle-group": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz",
+ "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-roving-focus": "1.1.11",
+ "@radix-ui/react-toggle": "1.1.10",
+ "@radix-ui/react-use-controllable-state": "1.2.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-toolbar": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.11.tgz",
+ "integrity": "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-roving-focus": "1.1.11",
+ "@radix-ui/react-separator": "1.1.7",
+ "@radix-ui/react-toggle-group": "1.1.11"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tooltip": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
+ "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-popper": "1.2.8",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-visually-hidden": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-callback-ref": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
+ "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-controllable-state": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
+ "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-effect-event": "0.0.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-effect-event": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
+ "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-escape-keydown": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
+ "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-callback-ref": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-is-hydrated": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz",
+ "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==",
+ "license": "MIT",
+ "dependencies": {
+ "use-sync-external-store": "^1.5.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
}
},
- "node_modules/@opentelemetry/semantic-conventions": {
- "version": "1.40.0",
- "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz",
- "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==",
- "license": "Apache-2.0",
- "engines": {
- "node": ">=14"
+ "node_modules/@radix-ui/react-use-layout-effect": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
+ "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
}
},
- "node_modules/@posthog/core": {
- "version": "1.23.2",
- "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.23.2.tgz",
- "integrity": "sha512-zTDdda9NuSHrnwSOfFMxX/pyXiycF4jtU1kTr8DL61dHhV+7LF6XF1ndRZZTuaGGbfbb/GJYkEsjEX9SXfNZeQ==",
+ "node_modules/@radix-ui/react-use-previous": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
+ "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
"license": "MIT",
- "dependencies": {
- "cross-spawn": "^7.0.6"
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
}
},
- "node_modules/@posthog/types": {
- "version": "1.359.1",
- "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.359.1.tgz",
- "integrity": "sha512-oQihoHWLnOkSkzOToCWKNigbJ7UZcIkl+rSJuq2PLwL7EB0Q/r1UGSbVCkrPH8xtPbYpi7w4TVpMrg41TMT+LQ==",
- "license": "MIT"
- },
- "node_modules/@protobufjs/aspromise": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
- "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/base64": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
- "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/codegen": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
- "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/eventemitter": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
- "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/fetch": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
- "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
- "license": "BSD-3-Clause",
+ "node_modules/@radix-ui/react-use-rect": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
+ "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
+ "license": "MIT",
"dependencies": {
- "@protobufjs/aspromise": "^1.1.1",
- "@protobufjs/inquire": "^1.1.0"
+ "@radix-ui/rect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
}
},
- "node_modules/@protobufjs/float": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
- "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/inquire": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
- "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/path": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
- "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/pool": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
- "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/utf8": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
- "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@radix-ui/react-compose-refs": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
- "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
+ "node_modules/@radix-ui/react-use-size": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
+ "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
@@ -1258,23 +2791,35 @@
}
}
},
- "node_modules/@radix-ui/react-slot": {
+ "node_modules/@radix-ui/react-visually-hidden": {
"version": "1.2.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
- "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
+ "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
+ "license": "MIT",
"dependencies": {
- "@radix-ui/react-compose-refs": "1.1.2"
+ "@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
}
}
},
+ "node_modules/@radix-ui/rect": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
+ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
+ "license": "MIT"
+ },
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1699,7 +3244,7 @@
"version": "19.1.9",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz",
"integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==",
- "dev": true,
+ "devOptional": true,
"peerDependencies": {
"@types/react": "^19.0.0"
}
@@ -2399,6 +3944,18 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true
},
+ "node_modules/aria-hidden": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
+ "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/aria-query": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
@@ -3169,6 +4726,12 @@
"node": ">=8"
}
},
+ "node_modules/detect-node-es": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
+ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
+ "license": "MIT"
+ },
"node_modules/devlop": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
@@ -3835,6 +5398,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/eventsource-parser": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-1.1.2.tgz",
+ "integrity": "sha512-v0eOBUbiaFojBu2s2NPBfYUoRR9GjcDNvCXVaqEf5vVfpIAh9f8RCo4vXTP8c63QRKCFwoLpMpTdPwwhEKVgzA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.18"
+ }
+ },
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -4113,6 +5685,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/get-nonce": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
+ "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
@@ -6476,6 +8057,83 @@
}
]
},
+ "node_modules/radix-ui": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz",
+ "integrity": "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-accessible-icon": "1.1.7",
+ "@radix-ui/react-accordion": "1.2.12",
+ "@radix-ui/react-alert-dialog": "1.1.15",
+ "@radix-ui/react-arrow": "1.1.7",
+ "@radix-ui/react-aspect-ratio": "1.1.7",
+ "@radix-ui/react-avatar": "1.1.10",
+ "@radix-ui/react-checkbox": "1.3.3",
+ "@radix-ui/react-collapsible": "1.1.12",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-context-menu": "2.2.16",
+ "@radix-ui/react-dialog": "1.1.15",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-dropdown-menu": "2.1.16",
+ "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-form": "0.1.8",
+ "@radix-ui/react-hover-card": "1.1.15",
+ "@radix-ui/react-label": "2.1.7",
+ "@radix-ui/react-menu": "2.1.16",
+ "@radix-ui/react-menubar": "1.1.16",
+ "@radix-ui/react-navigation-menu": "1.2.14",
+ "@radix-ui/react-one-time-password-field": "0.1.8",
+ "@radix-ui/react-password-toggle-field": "0.1.3",
+ "@radix-ui/react-popover": "1.1.15",
+ "@radix-ui/react-popper": "1.2.8",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-progress": "1.1.7",
+ "@radix-ui/react-radio-group": "1.3.8",
+ "@radix-ui/react-roving-focus": "1.1.11",
+ "@radix-ui/react-scroll-area": "1.2.10",
+ "@radix-ui/react-select": "2.2.6",
+ "@radix-ui/react-separator": "1.1.7",
+ "@radix-ui/react-slider": "1.3.6",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-switch": "1.2.6",
+ "@radix-ui/react-tabs": "1.1.13",
+ "@radix-ui/react-toast": "1.2.15",
+ "@radix-ui/react-toggle": "1.1.10",
+ "@radix-ui/react-toggle-group": "1.1.11",
+ "@radix-ui/react-toolbar": "1.1.11",
+ "@radix-ui/react-tooltip": "1.2.8",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-effect-event": "0.0.2",
+ "@radix-ui/react-use-escape-keydown": "1.1.1",
+ "@radix-ui/react-use-is-hydrated": "0.1.0",
+ "@radix-ui/react-use-layout-effect": "1.1.1",
+ "@radix-ui/react-use-size": "1.1.1",
+ "@radix-ui/react-visually-hidden": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
@@ -6501,6 +8159,75 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true
},
+ "node_modules/react-remove-scroll": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
+ "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "react-remove-scroll-bar": "^2.3.7",
+ "react-style-singleton": "^2.2.3",
+ "tslib": "^2.1.0",
+ "use-callback-ref": "^1.3.3",
+ "use-sidecar": "^1.1.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-remove-scroll-bar": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
+ "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
+ "license": "MIT",
+ "dependencies": {
+ "react-style-singleton": "^2.2.2",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-style-singleton": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
+ "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
+ "license": "MIT",
+ "dependencies": {
+ "get-nonce": "^1.0.0",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -6722,6 +8449,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/robot3": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/robot3/-/robot3-0.4.1.tgz",
+ "integrity": "sha512-hzjy826lrxzx8eRgv80idkf8ua1JAepRc9Efdtj03N3KNJuznQCPlyCJ7gnUmDFwZCLQjxy567mQVKmdv2BsXQ==",
+ "license": "BSD-2-Clause"
+ },
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -7673,6 +9406,49 @@
"requires-port": "^1.0.0"
}
},
+ "node_modules/use-callback-ref": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
+ "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/use-sidecar": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
+ "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
+ "license": "MIT",
+ "dependencies": {
+ "detect-node-es": "^1.1.0",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
diff --git a/package.json b/package.json
index 94e9410..6dc275f 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,9 @@
"lint": "eslint"
},
"dependencies": {
+ "@fal-ai/client": "^1.9.5",
+ "@hugeicons/core-free-icons": "^4.1.1",
+ "@hugeicons/react": "^1.1.6",
"@radix-ui/react-slot": "^1.2.3",
"@tailwindcss/typography": "^0.5.19",
"@vercel/analytics": "^1.5.0",
@@ -23,6 +26,7 @@
"next": "15.5.7",
"next-themes": "^0.4.6",
"posthog-js": "^1.359.1",
+ "radix-ui": "^1.4.3",
"react": "19.1.0",
"react-dom": "19.1.0",
"rehype-slug": "^6.0.0",
diff --git a/store/useStudioStore.ts b/store/useStudioStore.ts
new file mode 100644
index 0000000..76d96b6
--- /dev/null
+++ b/store/useStudioStore.ts
@@ -0,0 +1,179 @@
+import { create } from "zustand";
+import type {
+ ImageAspectRatio,
+ ImageAIModel,
+ ImageResolution,
+ StudioCanvasAsset,
+ StudioGalleryViewerState,
+ StudioMode,
+ VideoAspectRatio,
+ VideoAIModel,
+} from "@/types/studio";
+
+interface StudioState {
+ mode: StudioMode;
+ setMode: (mode: StudioMode) => void;
+
+ prompt: string;
+ setPrompt: (prompt: string) => void;
+
+ attachments: { file: File; preview: string }[];
+ addAttachment: (file: File) => void;
+ removeAttachment: (index: number) => void;
+ clearAttachments: () => void;
+
+ imageAspectRatio: ImageAspectRatio;
+ imageModel: ImageAIModel;
+ imageCount: number;
+ imageResolution: ImageResolution;
+ setImageAspectRatio: (value: ImageAspectRatio) => void;
+ setImageModel: (value: ImageAIModel) => void;
+ setImageCount: (value: number) => void;
+ setImageResolution: (value: ImageResolution) => void;
+
+ videoAspectRatio: VideoAspectRatio;
+ videoDuration: number;
+ videoModel: VideoAIModel;
+ firstFrame: File | null;
+ firstFramePreview: string | null;
+ lastFrame: File | null;
+ lastFramePreview: string | null;
+ videoAudio: boolean;
+ setVideoAspectRatio: (value: VideoAspectRatio) => void;
+ setVideoDuration: (value: number) => void;
+ setVideoModel: (value: VideoAIModel) => void;
+ setFirstFrame: (file: File | null) => void;
+ setLastFrame: (file: File | null) => void;
+ setVideoAudio: (value: boolean) => void;
+
+ canvasAssets: StudioCanvasAsset[];
+ appendCanvasAssets: (assets: StudioCanvasAsset[]) => void;
+
+ selectedAssetId: StudioGalleryViewerState["selectedAssetId"];
+ openViewer: (assetId: string) => void;
+ closeViewer: () => void;
+ goToNext: () => void;
+ goToPrevious: () => void;
+}
+
+function revokePreview(preview: string | null) {
+ if (preview) {
+ URL.revokeObjectURL(preview);
+ }
+}
+
+export const useStudioStore = create((set, get) => ({
+ mode: "image",
+ setMode: (mode) => set({ mode }),
+
+ prompt: "",
+ setPrompt: (prompt) => set({ prompt }),
+
+ attachments: [],
+ addAttachment: (file) => {
+ const preview = URL.createObjectURL(file);
+ set((state) => ({
+ attachments: [...state.attachments, { file, preview }],
+ }));
+ },
+ removeAttachment: (index) => {
+ const attachments = get().attachments;
+ const removed = attachments[index];
+
+ revokePreview(removed?.preview ?? null);
+
+ set({
+ attachments: attachments.filter((_, currentIndex) => currentIndex !== index),
+ });
+ },
+ clearAttachments: () => {
+ const attachments = get().attachments;
+ attachments.forEach((attachment) => revokePreview(attachment.preview));
+ set({ attachments: [] });
+ },
+
+ imageAspectRatio: "1:1",
+ imageModel: "nano-banana-2",
+ imageCount: 1,
+ imageResolution: "2048",
+ setImageAspectRatio: (value) => set({ imageAspectRatio: value }),
+ setImageModel: (value) => set({ imageModel: value }),
+ setImageCount: (value) => set({ imageCount: value }),
+ setImageResolution: (value) => set({ imageResolution: value }),
+
+ videoAspectRatio: "16:9",
+ videoDuration: 5,
+ videoModel: "kling-3",
+ firstFrame: null,
+ firstFramePreview: null,
+ lastFrame: null,
+ lastFramePreview: null,
+ videoAudio: true,
+ setVideoAspectRatio: (value) => set({ videoAspectRatio: value }),
+ setVideoDuration: (value) => set({ videoDuration: value }),
+ setVideoModel: (value) => set({ videoModel: value }),
+ setVideoAudio: (value) => set({ videoAudio: value }),
+ setFirstFrame: (file) => {
+ revokePreview(get().firstFramePreview);
+ set({
+ firstFrame: file,
+ firstFramePreview: file ? URL.createObjectURL(file) : null,
+ });
+ },
+ setLastFrame: (file) => {
+ revokePreview(get().lastFramePreview);
+ set({
+ lastFrame: file,
+ lastFramePreview: file ? URL.createObjectURL(file) : null,
+ });
+ },
+
+ canvasAssets: [],
+ appendCanvasAssets: (assets) =>
+ set((state) => ({
+ canvasAssets: [...state.canvasAssets, ...assets],
+ })),
+
+ selectedAssetId: null,
+ openViewer: (assetId) => set({ selectedAssetId: assetId }),
+ closeViewer: () => set({ selectedAssetId: null }),
+ goToNext: () =>
+ set((state) => {
+ if (!state.selectedAssetId) {
+ return state;
+ }
+
+ const currentIndex = state.canvasAssets.findIndex(
+ (asset) => asset.id === state.selectedAssetId
+ );
+
+ if (
+ currentIndex < 0 ||
+ currentIndex >= state.canvasAssets.length - 1
+ ) {
+ return state;
+ }
+
+ return {
+ selectedAssetId: state.canvasAssets[currentIndex + 1].id,
+ };
+ }),
+ goToPrevious: () =>
+ set((state) => {
+ if (!state.selectedAssetId) {
+ return state;
+ }
+
+ const currentIndex = state.canvasAssets.findIndex(
+ (asset) => asset.id === state.selectedAssetId
+ );
+
+ if (currentIndex <= 0) {
+ return state;
+ }
+
+ return {
+ selectedAssetId: state.canvasAssets[currentIndex - 1].id,
+ };
+ }),
+}));
diff --git a/types/studio.ts b/types/studio.ts
new file mode 100644
index 0000000..c80856e
--- /dev/null
+++ b/types/studio.ts
@@ -0,0 +1,107 @@
+export type StudioMode = "image" | "video";
+
+export type ImageAspectRatio =
+ | "auto"
+ | "1:1"
+ | "16:9"
+ | "9:16"
+ | "4:3"
+ | "3:4";
+
+export type VideoAspectRatio = "1:1" | "16:9" | "9:16";
+
+export type AspectRatio = ImageAspectRatio | VideoAspectRatio;
+
+export type ImageAIModel = "nano-banana-2";
+export type VideoAIModel = "kling-3";
+export type AIModel = ImageAIModel | VideoAIModel;
+
+export type ImageResolution = "1024" | "2048" | "4096";
+
+export type GenerationStatus =
+ | "idle"
+ | "submitting"
+ | "processing"
+ | "success"
+ | "error";
+
+export type ProviderJobStatus = "queued" | "processing" | "completed" | "failed";
+
+export interface GeneratedMediaOutput {
+ url: string;
+ contentType?: string;
+ fileName?: string;
+}
+
+export interface ImageGenerationSettings {
+ aspectRatio: ImageAspectRatio;
+ model: ImageAIModel;
+ count: number;
+ resolution: ImageResolution;
+ isEdit: boolean;
+ attachmentCount: number;
+}
+
+export interface VideoGenerationSettings {
+ aspectRatio: VideoAspectRatio;
+ duration: number;
+ model: VideoAIModel;
+ audio: boolean;
+ firstFrame?: string;
+ lastFrame?: string;
+}
+
+export type GenerationSettings =
+ | ImageGenerationSettings
+ | VideoGenerationSettings;
+
+export interface GenerationResult {
+ jobId: string;
+ status: "success";
+ type: StudioMode;
+ urls: string[];
+ outputs: GeneratedMediaOutput[];
+ prompt: string;
+ providerModel: string;
+ settings: GenerationSettings;
+ createdAt: string;
+}
+
+export interface StudioCanvasAsset {
+ id: string;
+ jobId: string;
+ outputIndex: number;
+ type: StudioMode;
+ url: string;
+ prompt: string;
+ providerModel: string;
+ createdAt: string;
+ contentType?: string;
+ fileName?: string;
+ aspectRatio?: AspectRatio;
+}
+
+export interface StudioGalleryViewerState {
+ selectedAssetId: string | null;
+}
+
+export interface GenerationJobResponse {
+ jobId: string;
+ status: ProviderJobStatus;
+ type: StudioMode;
+ outputs: GeneratedMediaOutput[];
+ prompt: string;
+ providerModel: string;
+ requestSettings: GenerationSettings;
+ createdAt: string;
+ error?: string;
+}
+
+export interface StudioGenerationState {
+ status: GenerationStatus;
+ isSubmitting: boolean;
+ isProcessing: boolean;
+ isGenerating: boolean;
+ result: GenerationResult | null;
+ error: string | null;
+}