From cc4b13c16926c6ce5759f3b60db415147f91ee0f Mon Sep 17 00:00:00 2001 From: Leo Constantin Date: Wed, 5 Aug 2026 17:11:19 +0200 Subject: [PATCH 1/6] developing video design and background --- apps/dashboard/app/(app)/layout.tsx | 2 + .../aspect-ratio/aspect-ratio-dropdown.tsx | 101 + .../aspect-ratio/aspect-ratio-picker.tsx | 343 +++ .../components/aspect-ratio/index.ts | 4 + .../components/canvas/CanvasRulers.tsx | 354 +++ .../components/canvas/ClientCanvas.tsx | 758 ++++++ .../canvas/frames/BrowserToolbar.tsx | 234 ++ .../canvas/frames/Frame3DOverlay.tsx | 113 + .../canvas/hooks/useImageLoading.ts | 256 +++ .../canvas/html/HTMLBackgroundLayer.tsx | 292 +++ .../canvas/html/HTMLBlurRegionLayer.tsx | 317 +++ .../canvas/html/HTMLCanvasRenderer.tsx | 48 + .../components/canvas/html/HTMLGridLayer.tsx | 31 + .../canvas/html/HTMLImageOverlayLayer.tsx | 373 +++ .../canvas/html/HTMLMainImageLayer.tsx | 850 +++++++ .../components/canvas/html/HTMLNoiseLayer.tsx | 37 + .../canvas/html/HTMLPatternLayer.tsx | 49 + .../canvas/html/HTMLTextOverlayLayer.tsx | 165 ++ .../canvas/html/SVGAnnotationLayer.tsx | 794 +++++++ .../canvas/html/SnapAlignmentGuides.tsx | 65 + .../dashboard/components/canvas/html/index.ts | 12 + .../canvas/overlays/ArcFrameOverlay.tsx | 218 ++ .../canvas/overlays/Perspective3DOverlay.tsx | 273 +++ .../canvas/utils/canvas-dimensions.ts | 162 ++ .../components/canvas/utils/gradient-utils.ts | 90 + .../components/canvas/utils/shadow-utils.ts | 107 + .../components/controls/CleanUploadState.tsx | 534 +++++ .../components/mockups/HTMLMockupRenderer.tsx | 139 ++ .../components/mockups/MockupControls.tsx | 234 ++ .../components/mockups/MockupGallery.tsx | 205 ++ .../components/mockups/MockupRenderer.tsx | 24 + apps/dashboard/components/mockups/index.ts | 4 + apps/dashboard/components/ui/cached-image.tsx | 58 + apps/dashboard/components/ui/color-picker.tsx | 536 +++++ .../components/ui/segmented-control.tsx | 85 + apps/dashboard/components/ui/slider.tsx | 94 + .../features/app/_layout/disconnected.tsx | 21 + apps/dashboard/features/app/demo/header.tsx | 2 +- .../sidebar/{agent.tsx => agent/index.tsx} | 0 .../features/app/demo/sidebar/background.tsx | 76 - .../app/demo/sidebar/background/index.tsx | 523 +++++ .../demo/sidebar/design/border-selection.tsx | 106 + .../demo/sidebar/design/browser-mockup.tsx | 266 +++ .../sidebar/{design.tsx => design/index.tsx} | 27 +- .../demo/sidebar/design/shadow-section.tsx | 90 + .../demo/sidebar/design/style-selection.tsx | 156 ++ .../features/app/demo/sidebar/index.tsx | 5 +- .../app/demo/video-editor/aspect-ratio.tsx | 69 +- .../features/app/demo/video-editor/footer.tsx | 12 +- .../features/app/demo/video-editor/index.tsx | 7 +- .../app/demo/video-editor/studio-canvas.tsx | 113 + .../hooks/use-aspect-ratio-dimensions.ts | 137 ++ apps/dashboard/lib/aspect-ratio-utils.ts | 200 ++ apps/dashboard/lib/constants.ts | 161 ++ apps/dashboard/lib/constants/aspect-ratios.ts | 278 +++ apps/dashboard/lib/constants/backgrounds.ts | 144 ++ apps/dashboard/lib/constants/fonts.ts | 484 ++++ .../lib/constants/gradient-colors.ts | 107 + apps/dashboard/lib/constants/index.ts | 7 + .../dashboard/lib/constants/mesh-gradients.ts | 176 ++ apps/dashboard/lib/constants/mockups.ts | 69 + apps/dashboard/lib/constants/overlays.ts | 7 + apps/dashboard/lib/constants/presets.ts | 339 +++ apps/dashboard/lib/constants/solid-colors.ts | 51 + apps/dashboard/lib/export/export-utils.ts | 462 ++++ apps/dashboard/lib/patterns.ts | 82 + apps/dashboard/lib/r2/index.ts | 127 + apps/dashboard/lib/r2/r2-backgrounds.ts | 183 ++ apps/dashboard/lib/r2/r2-demo-images.ts | 27 + apps/dashboard/lib/r2/r2-overlays.ts | 107 + apps/dashboard/lib/store/export-utils.ts | 56 + apps/dashboard/lib/store/index.ts | 2033 +++++++++++++++++ .../lib/workers/export-worker-service.ts | 657 ++++++ apps/dashboard/lib/workers/export.worker.ts | 435 ++++ apps/dashboard/lib/workers/index.ts | 13 + apps/dashboard/next.config.ts | 5 + apps/dashboard/package.json | 11 +- apps/dashboard/types/mockup.ts | 36 + biome.jsonc | 3 + pnpm-lock.yaml | 1305 +++++++++-- 80 files changed, 16882 insertions(+), 254 deletions(-) create mode 100644 apps/dashboard/components/aspect-ratio/aspect-ratio-dropdown.tsx create mode 100644 apps/dashboard/components/aspect-ratio/aspect-ratio-picker.tsx create mode 100644 apps/dashboard/components/aspect-ratio/index.ts create mode 100644 apps/dashboard/components/canvas/CanvasRulers.tsx create mode 100644 apps/dashboard/components/canvas/ClientCanvas.tsx create mode 100644 apps/dashboard/components/canvas/frames/BrowserToolbar.tsx create mode 100644 apps/dashboard/components/canvas/frames/Frame3DOverlay.tsx create mode 100644 apps/dashboard/components/canvas/hooks/useImageLoading.ts create mode 100644 apps/dashboard/components/canvas/html/HTMLBackgroundLayer.tsx create mode 100644 apps/dashboard/components/canvas/html/HTMLBlurRegionLayer.tsx create mode 100644 apps/dashboard/components/canvas/html/HTMLCanvasRenderer.tsx create mode 100644 apps/dashboard/components/canvas/html/HTMLGridLayer.tsx create mode 100644 apps/dashboard/components/canvas/html/HTMLImageOverlayLayer.tsx create mode 100644 apps/dashboard/components/canvas/html/HTMLMainImageLayer.tsx create mode 100644 apps/dashboard/components/canvas/html/HTMLNoiseLayer.tsx create mode 100644 apps/dashboard/components/canvas/html/HTMLPatternLayer.tsx create mode 100644 apps/dashboard/components/canvas/html/HTMLTextOverlayLayer.tsx create mode 100644 apps/dashboard/components/canvas/html/SVGAnnotationLayer.tsx create mode 100644 apps/dashboard/components/canvas/html/SnapAlignmentGuides.tsx create mode 100644 apps/dashboard/components/canvas/html/index.ts create mode 100644 apps/dashboard/components/canvas/overlays/ArcFrameOverlay.tsx create mode 100644 apps/dashboard/components/canvas/overlays/Perspective3DOverlay.tsx create mode 100644 apps/dashboard/components/canvas/utils/canvas-dimensions.ts create mode 100644 apps/dashboard/components/canvas/utils/gradient-utils.ts create mode 100644 apps/dashboard/components/canvas/utils/shadow-utils.ts create mode 100644 apps/dashboard/components/controls/CleanUploadState.tsx create mode 100644 apps/dashboard/components/mockups/HTMLMockupRenderer.tsx create mode 100644 apps/dashboard/components/mockups/MockupControls.tsx create mode 100644 apps/dashboard/components/mockups/MockupGallery.tsx create mode 100644 apps/dashboard/components/mockups/MockupRenderer.tsx create mode 100644 apps/dashboard/components/mockups/index.ts create mode 100644 apps/dashboard/components/ui/cached-image.tsx create mode 100644 apps/dashboard/components/ui/color-picker.tsx create mode 100644 apps/dashboard/components/ui/segmented-control.tsx create mode 100644 apps/dashboard/components/ui/slider.tsx create mode 100644 apps/dashboard/features/app/_layout/disconnected.tsx rename apps/dashboard/features/app/demo/sidebar/{agent.tsx => agent/index.tsx} (100%) delete mode 100644 apps/dashboard/features/app/demo/sidebar/background.tsx create mode 100644 apps/dashboard/features/app/demo/sidebar/background/index.tsx create mode 100644 apps/dashboard/features/app/demo/sidebar/design/border-selection.tsx create mode 100644 apps/dashboard/features/app/demo/sidebar/design/browser-mockup.tsx rename apps/dashboard/features/app/demo/sidebar/{design.tsx => design/index.tsx} (61%) create mode 100644 apps/dashboard/features/app/demo/sidebar/design/shadow-section.tsx create mode 100644 apps/dashboard/features/app/demo/sidebar/design/style-selection.tsx create mode 100644 apps/dashboard/features/app/demo/video-editor/studio-canvas.tsx create mode 100644 apps/dashboard/hooks/use-aspect-ratio-dimensions.ts create mode 100644 apps/dashboard/lib/aspect-ratio-utils.ts create mode 100644 apps/dashboard/lib/constants/aspect-ratios.ts create mode 100644 apps/dashboard/lib/constants/backgrounds.ts create mode 100644 apps/dashboard/lib/constants/fonts.ts create mode 100644 apps/dashboard/lib/constants/gradient-colors.ts create mode 100644 apps/dashboard/lib/constants/index.ts create mode 100644 apps/dashboard/lib/constants/mesh-gradients.ts create mode 100644 apps/dashboard/lib/constants/mockups.ts create mode 100644 apps/dashboard/lib/constants/overlays.ts create mode 100644 apps/dashboard/lib/constants/presets.ts create mode 100644 apps/dashboard/lib/constants/solid-colors.ts create mode 100644 apps/dashboard/lib/export/export-utils.ts create mode 100644 apps/dashboard/lib/patterns.ts create mode 100644 apps/dashboard/lib/r2/index.ts create mode 100644 apps/dashboard/lib/r2/r2-backgrounds.ts create mode 100644 apps/dashboard/lib/r2/r2-demo-images.ts create mode 100644 apps/dashboard/lib/r2/r2-overlays.ts create mode 100644 apps/dashboard/lib/store/export-utils.ts create mode 100644 apps/dashboard/lib/store/index.ts create mode 100644 apps/dashboard/lib/workers/export-worker-service.ts create mode 100644 apps/dashboard/lib/workers/export.worker.ts create mode 100644 apps/dashboard/lib/workers/index.ts create mode 100644 apps/dashboard/types/mockup.ts diff --git a/apps/dashboard/app/(app)/layout.tsx b/apps/dashboard/app/(app)/layout.tsx index 216676e..d170372 100644 --- a/apps/dashboard/app/(app)/layout.tsx +++ b/apps/dashboard/app/(app)/layout.tsx @@ -1,9 +1,11 @@ import { TooltipProvider } from "@castfy/ui/components/tooltip"; +import Disconnected from "@/features/app/_layout/disconnected"; export default function Lyout(props: LayoutProps<"/">) { return (
{props.children} +
); } diff --git a/apps/dashboard/components/aspect-ratio/aspect-ratio-dropdown.tsx b/apps/dashboard/components/aspect-ratio/aspect-ratio-dropdown.tsx new file mode 100644 index 0000000..0c21077 --- /dev/null +++ b/apps/dashboard/components/aspect-ratio/aspect-ratio-dropdown.tsx @@ -0,0 +1,101 @@ +import { Button } from "@castfy/ui/components/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@castfy/ui/components/popover"; +import { ArrowDown01Icon } from "hugeicons-react"; +import React from "react"; +import { aspectRatios } from "@/lib/constants/aspect-ratios"; +import { useImageStore } from "@/lib/store"; +import { AspectRatioPicker } from "./aspect-ratio-picker"; + +const popularRatios = ["1_1", "9_16", "16_9", "4_5", "og_image"]; + +export const AspectRatioDropdown = () => { + const { selectedAspectRatio, setAspectRatio } = useImageStore(); + const current = aspectRatios.find((ar) => ar.id === selectedAspectRatio); + const [open, setOpen] = React.useState(false); + + const handleQuickSelect = (id: string) => { + setAspectRatio(id); + }; + + return ( + +
+ + + + +
+ Quick: +
+ {popularRatios.map((id) => { + const ratio = aspectRatios.find((ar) => ar.id === id); + if (!ratio) { + return null; + } + const isSelected = selectedAspectRatio === id; + return ( + + ); + })} +
+
+
+ + setOpen(false)} /> + +
+ ); +}; diff --git a/apps/dashboard/components/aspect-ratio/aspect-ratio-picker.tsx b/apps/dashboard/components/aspect-ratio/aspect-ratio-picker.tsx new file mode 100644 index 0000000..365f77e --- /dev/null +++ b/apps/dashboard/components/aspect-ratio/aspect-ratio-picker.tsx @@ -0,0 +1,343 @@ +"use client"; + +import { cn } from "@castfy/ui/lib/utils"; +import { + AppStoreIcon, + DribbbleIcon, + InstagramIcon, + NewTwitterIcon, + PinterestIcon, + YoutubeIcon, +} from "hugeicons-react"; +import React from "react"; +import { getStandardDimensions } from "@/lib/aspect-ratio-utils"; +import { aspectRatios } from "@/lib/constants/aspect-ratios"; +import { useImageStore } from "@/lib/store"; + +interface AspectRatioPickerProps { + onSelect?: () => void; +} + +const standardRatioIds = [ + "16_9", + "3_2", + "4_3", + "5_4", + "1_1", + "4_5", + "3_4", + "2_3", + "9_16", +]; + +const socialSections = [ + { + name: "Instagram", + icon: InstagramIcon, + presets: [ + { label: "Post", ratio: "1:1", id: "1_1" }, + { label: "Portrait", ratio: "4:5", id: "4_5" }, + { label: "Story", ratio: "9:16", id: "9_16" }, + ], + }, + { + name: "Twitter", + icon: NewTwitterIcon, + presets: [ + { label: "Tweet", ratio: "16:9", id: "16_9" }, + { label: "Cover", ratio: "3:1", id: "twitter_banner" }, + ], + }, + { + name: "YouTube", + icon: YoutubeIcon, + presets: [ + { label: "Banner", ratio: "16:9", id: "youtube_banner" }, + { label: "Thumbnail", ratio: "16:9", id: "youtube_thumbnail" }, + { label: "Video", ratio: "16:9", id: "youtube_video" }, + ], + }, + { + name: "Pinterest", + icon: PinterestIcon, + presets: [ + { label: "Long", ratio: "10:21", id: "pinterest_long" }, + { label: "Optimal", ratio: "2:3", id: "2_3" }, + { label: "Square", ratio: "1:1", id: "1_1" }, + ], + }, + { + name: "Dribbble", + icon: DribbbleIcon, + presets: [{ label: "Shot", ratio: "4:3", id: "4_3" }], + }, + { + name: "App Store", + icon: AppStoreIcon, + presets: [ + { label: 'iPhone 6.5"', ratio: "1284:2778", id: "appstore_iphone65" }, + { label: 'iPhone 5.5"', ratio: "1242:2208", id: "appstore_iphone55" }, + { label: 'iPad Pro 12.9"', ratio: "2048:2732", id: "appstore_ipad" }, + { + label: 'iPhone 6.5" L', + ratio: "2778:1284", + id: "appstore_iphone65_landscape", + }, + { + label: 'iPhone 5.5" L', + ratio: "2208:1242", + id: "appstore_iphone55_landscape", + }, + { + label: "iPad Pro L", + ratio: "2732:2048", + id: "appstore_ipad_landscape", + }, + { label: "Mac", ratio: "16:10", id: "16_10" }, + ], + }, +]; + +function getShapeDimensions( + widthRatio: number, + heightRatio: number, + maxSize = 36 +) { + const ratio = widthRatio / heightRatio; + let w: number, h: number; + if (ratio >= 1) { + w = maxSize; + h = maxSize / ratio; + } else { + h = maxSize; + w = maxSize * ratio; + } + return { w: Math.max(w, 10), h: Math.max(h, 10) }; +} + +function parseRatio(ratioStr: string) { + const [w, h] = ratioStr.split(":").map(Number); + return { w, h }; +} + +export const AspectRatioPicker = ( + { onSelect }: AspectRatioPickerProps = {} as AspectRatioPickerProps +) => { + const { + selectedAspectRatio, + setAspectRatio, + customDimensions, + setCustomDimensions, + } = useImageStore(); + + const currentAR = aspectRatios.find((ar) => ar.id === selectedAspectRatio); + const currentDimensions = + selectedAspectRatio === "custom" && customDimensions + ? customDimensions + : currentAR + ? getStandardDimensions(currentAR.width, currentAR.height) + : { width: 1920, height: 1080 }; + + const [customW, setCustomW] = React.useState( + currentDimensions.width.toString() + ); + const [customH, setCustomH] = React.useState( + currentDimensions.height.toString() + ); + + React.useEffect(() => { + if (selectedAspectRatio === "custom" && customDimensions) { + setCustomW(customDimensions.width.toString()); + setCustomH(customDimensions.height.toString()); + } else if (currentAR) { + const dims = getStandardDimensions(currentAR.width, currentAR.height); + setCustomW(dims.width.toString()); + setCustomH(dims.height.toString()); + } + }, [selectedAspectRatio, currentAR, customDimensions]); + + const handleSelect = (id: string) => { + setAspectRatio(id); + onSelect?.(); + }; + + const isCustomChanged = + customW !== currentDimensions.width.toString() || + customH !== currentDimensions.height.toString(); + + const handleSetCustom = () => { + const w = Number.parseInt(customW, 10); + const h = Number.parseInt(customH, 10); + if (Number.isNaN(w) || Number.isNaN(h) || w <= 0 || h <= 0) { + return; + } + setCustomDimensions(w, h); + onSelect?.(); + }; + + return ( +
+ {/* Custom Dimensions */} +
+
+ + setCustomW(e.target.value)} + type="number" + value={customW} + /> +
+ × +
+ + setCustomH(e.target.value)} + type="number" + value={customH} + /> +
+ +
+ + {/* Standard Ratios */} +
+

+ Standard +

+
+ {standardRatioIds.map((id) => { + const ar = aspectRatios.find((a) => a.id === id); + if (!ar) { + return null; + } + const isSelected = selectedAspectRatio === id; + const { w, h } = getShapeDimensions(ar.width, ar.height); + return ( + + ); + })} +
+
+ + {/* Social Media Sections */} + {socialSections.map((section) => { + const Icon = section.icon; + return ( +
+
+
+ +

+ {section.name} +

+
+
+ {section.presets.map((preset) => { + const isSelected = selectedAspectRatio === preset.id; + const { w: rW, h: rH } = parseRatio(preset.ratio); + const { w, h } = getShapeDimensions(rW, rH); + const iconSize = Math.min(w, h) * 0.45; + return ( + + ); + })} +
+
+ ); + })} +
+ ); +}; diff --git a/apps/dashboard/components/aspect-ratio/index.ts b/apps/dashboard/components/aspect-ratio/index.ts new file mode 100644 index 0000000..8d88089 --- /dev/null +++ b/apps/dashboard/components/aspect-ratio/index.ts @@ -0,0 +1,4 @@ +// Export all aspect ratio components +export { AspectRatioDropdown } from './aspect-ratio-dropdown'; +export { AspectRatioPicker } from './aspect-ratio-picker'; + diff --git a/apps/dashboard/components/canvas/CanvasRulers.tsx b/apps/dashboard/components/canvas/CanvasRulers.tsx new file mode 100644 index 0000000..1446eac --- /dev/null +++ b/apps/dashboard/components/canvas/CanvasRulers.tsx @@ -0,0 +1,354 @@ +'use client' + +import { useCallback, useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react' + +const RULER_SIZE = 20 +const TICK_COLOR = 'rgba(128,128,128,0.6)' +const LABEL_COLOR = 'rgba(128,128,128,0.8)' +const FONT = '9px/1 ui-monospace, monospace' +const HIGHLIGHT_FILL = 'var(--primary)' + +/** Bounding box of the selected element, in canvas pixels. */ +interface RulerHighlight { + x: number + y: number + width: number + height: number +} + +/** Resolved geometry of the ruler relative to the center viewport. */ +interface Geometry { + /** Viewport (center section) box in screen coords. */ + vpLeft: number + vpTop: number + vpWidth: number + vpHeight: number + /** Canvas origin (value 0) measured from the viewport's top-left, in screen px. */ + originX: number + originY: number + /** Screen px per canvas px (1 unless the canvas is CSS-scaled). */ + scale: number + /** Axis-aligned box (canvas px) of the selected element, or null. */ + highlight: RulerHighlight | null +} + +function sameGeometry(a: Geometry | null, b: Geometry): boolean { + return ( + a != null && + a.vpLeft === b.vpLeft && + a.vpTop === b.vpTop && + a.vpWidth === b.vpWidth && + a.vpHeight === b.vpHeight && + a.originX === b.originX && + a.originY === b.originY && + a.scale === b.scale && + a.highlight?.x === b.highlight?.x && + a.highlight?.y === b.highlight?.y && + a.highlight?.width === b.highlight?.width && + a.highlight?.height === b.highlight?.height + ) +} + +const FLOAT_EPS = 1e-6 + +function isMajorValue(value: number, majorEvery: number): boolean { + const m = Math.abs(value % majorEvery) + return m < FLOAT_EPS || Math.abs(m - majorEvery) < FLOAT_EPS +} + +interface RulerBarProps { + orientation: 'horizontal' | 'vertical' + /** Full length of the bar (viewport width or height) in screen px. */ + length: number + /** Canvas origin offset along this axis, from the viewport edge, in screen px. */ + origin: number + scale: number + majorEvery: number + /** Selected element extent along this axis, in canvas px (or null). */ + highlightStart: number | null + highlightEnd: number | null +} + +function RulerBar({ orientation, length, origin, scale, majorEvery, highlightStart, highlightEnd }: RulerBarProps) { + const isHorizontal = orientation === 'horizontal' + const width = isHorizontal ? length : RULER_SIZE + const height = isHorizontal ? RULER_SIZE : length + + const safeMajor = Number.isFinite(majorEvery) && majorEvery > 0 ? majorEvery : 100 + const minorEvery = safeMajor / 2 + + // Selection band mapped to screen px along this axis. + const hasHighlight = highlightStart != null && highlightEnd != null + const bandA = hasHighlight ? origin + highlightStart * scale : 0 + const bandB = hasHighlight ? origin + highlightEnd * scale : 0 + const bandMin = Math.min(bandA, bandB) + const bandSize = Math.abs(bandB - bandA) + + const ticks: React.ReactNode[] = [] + + // Range of canvas-pixel values that fall within the visible viewport span. + const startValue = Math.floor(-origin / scale / minorEvery) * minorEvery + const endValue = Math.ceil((length - origin) / scale / minorEvery) * minorEvery + + for (let value = startValue; value <= endValue + FLOAT_EPS; value += minorEvery) { + const pos = origin + value * scale + if (pos < -1 || pos > length + 1) continue + + const isMajor = isMajorValue(value, safeMajor) + const tickLen = isMajor ? 10 : 5 + + if (isHorizontal) { + ticks.push( + , + ) + if (isMajor) { + ticks.push( + + {Math.round(value)} + , + ) + } + } else { + ticks.push( + , + ) + if (isMajor) { + ticks.push( + + {Math.round(value)} + , + ) + } + } + } + + return ( + + {/* Ruler background */} + + {/* Selection band — highlights where the selected element fits */} + {hasHighlight && + (isHorizontal ? ( + <> + + + + + ) : ( + <> + + + + + ))} + {/* Border on the canvas-facing edge */} + {isHorizontal ? ( + + ) : ( + + )} + {ticks} + + ) +} + +interface CanvasRulersProps { + canvasRef: RefObject + canvasW: number + majorEvery?: number + /** + * CSS selector for the currently selected element. Its rendered bounding box + * (after rotation/scale/flip) is measured and highlighted on the rulers. + */ + selectedSelector?: string | null +} + +export function CanvasRulers({ + canvasRef, + canvasW, + majorEvery = 100, + selectedSelector = null, +}: CanvasRulersProps) { + const [geo, setGeo] = useState(null) + const rafRef = useRef(null) + + const measure = useCallback(() => { + const canvasEl = canvasRef.current + if (!canvasEl) return + + const viewportEl = canvasEl.closest('[data-canvas-viewport]') as HTMLElement | null + if (!viewportEl) return + + const vp = viewportEl.getBoundingClientRect() + const cv = canvasEl.getBoundingClientRect() + const scale = canvasW > 0 && cv.width > 0 ? cv.width / canvasW : 1 + + let highlight: RulerHighlight | null = null + if (selectedSelector) { + const el = viewportEl.querySelector(selectedSelector) + if (el) { + const r = el.getBoundingClientRect() + highlight = { + x: (r.left - cv.left) / scale, + y: (r.top - cv.top) / scale, + width: r.width / scale, + height: r.height / scale, + } + } + } + + const next: Geometry = { + vpLeft: vp.left, + vpTop: vp.top, + vpWidth: vp.width, + vpHeight: vp.height, + originX: cv.left - vp.left, + originY: cv.top - vp.top, + scale, + highlight, + } + setGeo((prev) => (sameGeometry(prev, next) ? prev : next)) + }, [canvasRef, canvasW, selectedSelector]) + + const scheduleMeasure = useCallback(() => { + if (rafRef.current != null) return + rafRef.current = requestAnimationFrame(() => { + rafRef.current = null + measure() + }) + }, [measure]) + + useLayoutEffect(() => { + measure() + if (!selectedSelector) return + let raf = 0 + const tick = () => { + measure() + raf = requestAnimationFrame(tick) + } + raf = requestAnimationFrame(tick) + return () => cancelAnimationFrame(raf) + }, [measure, selectedSelector]) + + useEffect(() => { + const canvasEl = canvasRef.current + const viewportEl = canvasEl?.closest('[data-canvas-viewport]') as HTMLElement | null + + const ro = new ResizeObserver(scheduleMeasure) + if (viewportEl) ro.observe(viewportEl) + + window.addEventListener('resize', scheduleMeasure) + window.addEventListener('scroll', scheduleMeasure, true) + + return () => { + ro.disconnect() + window.removeEventListener('resize', scheduleMeasure) + window.removeEventListener('scroll', scheduleMeasure, true) + if (rafRef.current != null) cancelAnimationFrame(rafRef.current) + } + }, [canvasRef, scheduleMeasure]) + + if (!geo) return null + + return ( +
+ {/* Horizontal ruler — full viewport width along the top edge */} +
+ +
+ + {/* Vertical ruler — full viewport height along the left edge */} +
+ +
+ + {/* Corner block */} +
+
+ ) +} diff --git a/apps/dashboard/components/canvas/ClientCanvas.tsx b/apps/dashboard/components/canvas/ClientCanvas.tsx new file mode 100644 index 0000000..0a5f8ef --- /dev/null +++ b/apps/dashboard/components/canvas/ClientCanvas.tsx @@ -0,0 +1,758 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { MockupRenderer } from "@/components/mockups/MockupRenderer"; +import { useResponsiveCanvasDimensions } from "@/hooks/use-aspect-ratio-dimensions"; +import { generateNoiseTexture } from "@/lib/export/export-utils"; +import { generatePattern } from "@/lib/patterns"; +import { useEditorStore, useImageStore } from "@/lib/store"; +import { CanvasRulers } from "./CanvasRulers"; +import { useBackgroundImage, useOverlayImages } from "./hooks/useImageLoading"; +import { + HTMLBackgroundLayer, + HTMLBlurRegionLayer, + HTMLCanvasRenderer, + HTMLGridLayer, + HTMLImageOverlayLayer, + HTMLMainImageLayer, + HTMLNoiseLayer, + HTMLPatternLayer, + HTMLTextOverlayLayer, + SnapAlignmentGuides, + SVGAnnotationLayer, +} from "./html"; +import { Perspective3DOverlay } from "./overlays/Perspective3DOverlay"; +import { calculateCanvasDimensions } from "./utils/canvas-dimensions"; + +// Reference to the HTML canvas container for export +let globalCanvasContainer: HTMLDivElement | null = null; + +function CanvasRenderer({ image }: { image: HTMLImageElement }) { + const containerRef = useRef(null); + const canvasContainerRef = useRef(null); + const { + screenshot, + setScreenshot, + shadow, + pattern: patternStyle, + frame: editorFrame, + canvas, + noise, + } = useEditorStore(); + + const { + backgroundConfig, + backgroundBorderRadius, + backgroundBlur, + backgroundNoise, + perspective3D, + imageOpacity, + imageFilters, + textOverlays, + imageOverlays, + mockups, + imageBorder, + updateTextOverlay, + updateImageOverlay, + removeImageOverlay, + addImageOverlay, + // Annotations + annotations, + activeAnnotationTool, + selectedAnnotationId, + setSelectedAnnotationId, + annotationDefaults, + addAnnotation, + updateAnnotation: updateAnnotationShape, + removeAnnotation, + setActiveAnnotationTool, + // Blur + blurRegions, + addBlurRegion, + updateBlurRegion, + removeBlurRegion, + browserHeaderSize, + showRulers, + showGrid, + rulerInterval, + } = useImageStore(); + + // Split overlays into front (default) and back (behind main image) + const backOverlays = imageOverlays.filter((o) => o.layer === "back"); + const frontOverlays = imageOverlays.filter((o) => o.layer !== "back"); + + // Build frame from imageBorder directly (editorStore sync may be stale) + const frame = { + ...editorFrame, + enabled: imageBorder.enabled, + type: imageBorder.type, + width: imageBorder.width, + color: imageBorder.color, + padding: imageBorder.padding, + title: imageBorder.title, + opacity: imageBorder.opacity, + }; + + const hasMockups = mockups.length > 0 && mockups.some((m) => m.isVisible); + const responsiveDimensions = useResponsiveCanvasDimensions(); + + const [viewportSize, setViewportSize] = useState({ + width: 1920, + height: 1080, + }); + + const [patternImage, setPatternImage] = useState( + null + ); + const [noiseImage, setNoiseImage] = useState(null); + const [noiseTexture, setNoiseTexture] = useState( + null + ); + + const [selectedOverlayId, setSelectedOverlayId] = useState( + null + ); + const [isMainImageSelected, setIsMainImageSelected] = useState(false); + const [selectedTextId, setSelectedTextId] = useState(null); + const [isDraggingMainImage, setIsDraggingMainImage] = useState(false); + const [selectedBlurId, setSelectedBlurId] = useState(null); + + // 3D transform drag state — differentiates click (select) from drag (move) + const [is3DDragging, setIs3DDragging] = useState(false); + const [is3DPointerDown, setIs3DPointerDown] = useState(false); + const drag3DStartRef = useRef<{ + clientX: number; + clientY: number; + tX: number; + tY: number; + moved: boolean; + } | null>(null); + + const handle3DDragDown = useCallback((e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + const p3d = useImageStore.getState().perspective3D; + drag3DStartRef.current = { + clientX: e.clientX, + clientY: e.clientY, + tX: p3d.translateX, + tY: p3d.translateY, + moved: false, + }; + setIs3DPointerDown(true); + // Select the image on click/drag start + setIsMainImageSelected(true); + setSelectedOverlayId(null); + setSelectedTextId(null); + }, []); + + useEffect(() => { + if (!is3DPointerDown) { + return; + } + + const DRAG_THRESHOLD = 3; + + const handleMove = (e: PointerEvent) => { + const s = drag3DStartRef.current; + if (!s) { + return; + } + + const dx = e.clientX - s.clientX; + const dy = e.clientY - s.clientY; + + // Only start actual drag after threshold — clicks pass through + if (!s.moved && Math.sqrt(dx * dx + dy * dy) < DRAG_THRESHOLD) { + return; + } + s.moved = true; + setIs3DDragging(true); + + const sensitivity = 0.15; + const newTX = Math.max(-30, Math.min(30, s.tX + dx * sensitivity)); + const newTY = Math.max(-30, Math.min(30, s.tY + dy * sensitivity)); + + useImageStore.getState().setPerspective3D({ + translateX: Math.round(newTX * 10) / 10, + translateY: Math.round(newTY * 10) / 10, + }); + }; + + const handleUp = () => { + setIs3DDragging(false); + setIs3DPointerDown(false); + drag3DStartRef.current = null; + }; + + window.addEventListener("pointermove", handleMove); + window.addEventListener("pointerup", handleUp); + + return () => { + window.removeEventListener("pointermove", handleMove); + window.removeEventListener("pointerup", handleUp); + }; + }, [is3DPointerDown]); + + const containerWidth = responsiveDimensions.width; + const containerHeight = responsiveDimensions.height; + + const bgImage = useBackgroundImage( + backgroundConfig, + containerWidth, + containerHeight + ); + const loadedOverlayImages = useOverlayImages(imageOverlays); + + // Update global reference for export + useEffect(() => { + if (canvasContainerRef.current) { + globalCanvasContainer = canvasContainerRef.current; + } + return () => { + globalCanvasContainer = null; + }; + }, []); + + // Clear selection when clicking outside of canvas + useEffect(() => { + const handlePointerDown = (e: PointerEvent) => { + const target = e.target as Node | null; + if (!target) { + return; + } + + const container = containerRef.current; + if (!container) { + return; + } + + if (!container.contains(target)) { + // Don't deselect when interacting with editor panel controls + // (sliders, inputs, buttons, etc.) so users can tweak selected items + const el = target as HTMLElement; + if ( + el.closest?.( + '[data-slot="slider"], input, button, [role="button"], [data-radix-collection-item], .moveable-control-box, [data-resize-handle]' + ) + ) { + return; + } + + setSelectedOverlayId(null); + setIsMainImageSelected(false); + setSelectedTextId(null); + setSelectedBlurId(null); + setSelectedAnnotationId(null); + } + }; + + document.addEventListener("pointerdown", handlePointerDown, true); + return () => { + document.removeEventListener("pointerdown", handlePointerDown, true); + }; + }, [setSelectedAnnotationId]); + + // Keyboard shortcuts for delete and undo/redo + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Ignore if user is typing in an input, textarea, or contenteditable + const target = e.target as HTMLElement; + const isTyping = + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable; + + // Delete selected overlay or main image (only when not typing) + if ((e.key === "Delete" || e.key === "Backspace") && !isTyping) { + if (selectedOverlayId) { + e.preventDefault(); + removeImageOverlay(selectedOverlayId); + setSelectedOverlayId(null); + } else if (isMainImageSelected) { + e.preventDefault(); + useImageStore.getState().clearImage(); + setIsMainImageSelected(false); + } + } + + // Undo/Redo (only when not typing) + if ( + (e.metaKey || e.ctrlKey) && + e.key.toLowerCase() === "z" && + !isTyping + ) { + e.preventDefault(); + const { undo, redo } = useImageStore.temporal.getState(); + if (e.shiftKey) { + redo(); + } else { + undo(); + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [selectedOverlayId, removeImageOverlay, isMainImageSelected]); + + // Get selected overlay for toolbar positioning + const selectedOverlay = selectedOverlayId + ? imageOverlays.find((o) => o.id === selectedOverlayId) + : null; + + // Handle duplicate overlay + const handleDuplicateOverlay = () => { + if (!selectedOverlay) { + return; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id: _id, ...overlayWithoutId } = selectedOverlay; + addImageOverlay({ + ...overlayWithoutId, + position: { + x: selectedOverlay.position.x + 30, + y: selectedOverlay.position.y + 30, + }, + }); + }; + + // Handle delete overlay + const handleDeleteOverlay = () => { + if (!selectedOverlayId) { + return; + } + removeImageOverlay(selectedOverlayId); + setSelectedOverlayId(null); + }; + + useEffect(() => { + if (backgroundNoise > 0) { + const intensity = backgroundNoise / 100; + const noiseCanvas = generateNoiseTexture(200, 200, intensity); + setNoiseTexture(noiseCanvas); + } else { + setNoiseTexture(null); + } + }, [backgroundNoise]); + + useEffect(() => { + const updateViewportSize = () => { + setViewportSize({ + width: window.innerWidth, + height: window.innerHeight, + }); + }; + + updateViewportSize(); + window.addEventListener("resize", updateViewportSize); + return () => window.removeEventListener("resize", updateViewportSize); + }, []); + + useEffect(() => { + if (!patternStyle.enabled) { + setPatternImage(null); + return; + } + + const newPattern = generatePattern( + patternStyle.type, + patternStyle.scale, + patternStyle.spacing, + patternStyle.color, + patternStyle.rotation, + patternStyle.blur + ); + setPatternImage(newPattern); + }, [ + patternStyle.enabled, + patternStyle.type, + patternStyle.scale, + patternStyle.spacing, + patternStyle.color, + patternStyle.rotation, + patternStyle.blur, + ]); + + useEffect(() => { + if (!noise.enabled || noise.type === "none") { + setNoiseImage(null); + return; + } + + const img = new window.Image(); + img.crossOrigin = "anonymous"; + img.onload = () => setNoiseImage(img); + img.onerror = () => setNoiseImage(null); + img.src = `/${noise.type}.jpg`; + }, [noise.enabled, noise.type]); + + const dimensions = calculateCanvasDimensions( + image, + containerWidth, + containerHeight, + viewportSize, + canvas, + screenshot, + frame, + browserHeaderSize + ); + + const { + canvasW, + canvasH, + imageScaledW, + imageScaledH, + framedW, + framedH, + frameOffset, + windowPadding, + windowHeader, + eclipseBorder, + groupCenterX, + groupCenterY, + } = dimensions; + + // Store canvas dimensions so editor panels can calculate position presets + const setCanvasDimensions = useImageStore((s) => s.setCanvasDimensions); + useEffect(() => { + setCanvasDimensions({ canvasW, canvasH, framedW, framedH }); + }, [canvasW, canvasH, framedW, framedH, setCanvasDimensions]); + + const showFrame = frame.enabled && frame.type !== "none"; + + let selectedSelector: string | null = null; + if (isMainImageSelected) { + selectedSelector = '[data-main-image-layer="true"]'; + } else if (selectedOverlayId) { + selectedSelector = `[data-overlay-id="${CSS.escape(selectedOverlayId)}"]`; + } + + const has3DTransform = + perspective3D.rotateX !== 0 || + perspective3D.rotateY !== 0 || + perspective3D.rotateZ !== 0 || + perspective3D.translateX !== 0 || + perspective3D.translateY !== 0 || + perspective3D.scale !== 1; + + // Deselect everything on mousedown on the canvas background. + // Child elements (image, overlays) call e.stopPropagation() on mousedown, + // so this only fires when clicking empty canvas area. + const handleCanvasDeselect = (e: React.PointerEvent) => { + // Don't deselect when interacting with resize/rotate handles + const target = e.target as HTMLElement; + if (target.closest?.(".moveable-control-box, [data-resize-handle]")) { + return; + } + + setSelectedOverlayId(null); + setIsMainImageSelected(false); + setSelectedTextId(null); + setSelectedBlurId(null); + setSelectedAnnotationId(null); + }; + + return ( +
+
+ {showRulers && ( + + )} + + {/* Background Layer */} + + + {/* Pattern Layer */} + + + {/* Noise Layer */} + + + {/* 3D Transform Overlay - renders when 3D transforms are active */} + + + {/* 3D Drag Layer - allows dragging image when 3D transforms are active */} + {has3DTransform && ( +
+ )} + + {/* Back Image Overlays - rendered behind the main image */} + {backOverlays.length > 0 && ( + + )} + + {/* Main Image Layer - renders when no 3D transform and no mockups */} + {!(hasMockups || has3DTransform) && ( + <> + + + + )} + + {/* Mockups Layer */} + {mockups.map((mockup) => ( + + ))} + + {/* Text Overlay Layer */} + + + {/* Front Image Overlay Layer */} + + + {/* Blur Region Layer */} + + + {/* SVG Annotation Layer */} + { + addBlurRegion({ + position: { x: rect.x, y: rect.y }, + size: { width: rect.w, height: rect.h }, + blurAmount: 10, + isVisible: true, + }); + }} + removeAnnotation={removeAnnotation} + selectedAnnotationId={selectedAnnotationId} + setActiveAnnotationTool={setActiveAnnotationTool} + setSelectedAnnotationId={setSelectedAnnotationId} + updateAnnotation={updateAnnotationShape} + /> + + {/* Toolbar is now integrated inside HTMLImageOverlayLayer */} + + {/* Grid overlay — rendered on top of all content layers */} + {showGrid && } + +
+
+ ); +} + +export function getCanvasContainer(): HTMLDivElement | null { + return globalCanvasContainer; +} + +export default function ClientCanvas() { + const [image, setImage] = useState(null); + const [loadError, setLoadError] = useState(false); + const { screenshot, setScreenshot } = useEditorStore(); + const { uploadedImageUrl } = useImageStore(); + + // Load primary image from screenshot.src + useEffect(() => { + setLoadError(false); + + if (!(screenshot.src && uploadedImageUrl)) { + setImage(null); + return; + } + + const img = new window.Image(); + img.crossOrigin = "anonymous"; + + const timeoutId = setTimeout(() => { + if (!img.complete) { + console.warn("Image load timeout"); + setLoadError(true); + setScreenshot({ src: null }); + } + }, 10_000); + + img.onload = () => { + clearTimeout(timeoutId); + setImage(img); + }; + + img.onerror = () => { + clearTimeout(timeoutId); + console.warn("Image load error"); + setLoadError(true); + setScreenshot({ src: null }); + }; + + img.src = screenshot.src; + + return () => { + clearTimeout(timeoutId); + }; + }, [screenshot.src, uploadedImageUrl, setScreenshot]); + + if (loadError || !screenshot.src || !uploadedImageUrl) { + return null; + } + + if (!image) { + return ( +
+
+
+ ); + } + + return ; +} diff --git a/apps/dashboard/components/canvas/frames/BrowserToolbar.tsx b/apps/dashboard/components/canvas/frames/BrowserToolbar.tsx new file mode 100644 index 0000000..42879a3 --- /dev/null +++ b/apps/dashboard/components/canvas/frames/BrowserToolbar.tsx @@ -0,0 +1,234 @@ +'use client'; + +interface ToolbarProps { + windowHeader: number; + isDark: boolean; + title?: string; + /** Top border-radius — used in 3D overlay where the toolbar is the topmost element */ + screenshotRadius?: number; +} + +/** + * Safari toolbar — single unified bar matching macOS Sonoma/Sequoia. + * Layout: traffic lights | sidebar | ← → | [spacer] | reader | [lock url refresh] | [spacer] | globe share + tabs + */ +export function SafariToolbar({ windowHeader, isDark, title, screenshotRadius }: ToolbarProps) { + const bgColor = isDark ? '#3A3A3C' : '#F6F6F6'; + const iconColor = isDark ? 'rgba(255,255,255,0.55)' : 'rgba(0,0,0,0.5)'; + const pillBg = isDark ? 'rgba(0,0,0,0.15)' : 'rgba(0,0,0,0.04)'; + const urlColor = isDark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.55)'; + const borderColor = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.12)'; + const sf = '-apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif'; + const dot = Math.max(5, Math.round(windowHeader * 0.27)); + const ico = Math.max(7, Math.round(windowHeader * 0.36)); + const pillH = Math.max(12, Math.round(windowHeader * 0.55)); + const fs = Math.max(7, Math.round(windowHeader * 0.32)); + const pad = Math.max(6, Math.round(windowHeader * 0.32)); + const gap = Math.max(4, Math.round(windowHeader * 0.2)); + const smIco = Math.max(6, Math.round(ico * 0.85)); + + return ( +
+ {/* Left group */} +
+
+ + + +
+ + + + + + + + + + +
+ {/* Center group — reader + address bar pill */} +
+ + + + + +
+ + + + + + {title || ''} + +
+ + + +
+
+ {/* Right group */} +
+ + + + + + + + + + + + + + + + +
+
+ ); +} + +/** + * Chrome toolbar on macOS — two rows: tab bar + address bar. + * Tab bar: traffic lights | [tab title ×] | + + * Address bar: ← → ↻ | [lock url] | ⋮ + */ +export function ChromeToolbar({ windowHeader, isDark, title, screenshotRadius }: ToolbarProps) { + const tabBg = isDark ? '#202124' : '#DEE1E6'; + const activeBg = isDark ? '#292A2D' : '#FFFFFF'; + const iconColor = isDark ? '#9AA0A6' : '#5F6368'; + const urlColor = isDark ? '#E8EAED' : '#202124'; + const barInputBg = isDark ? '#35363A' : '#F1F3F4'; + const sf = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif'; + const tabH = Math.round(windowHeader * 0.47); + const addrH = windowHeader - tabH; + const tabRiseH = Math.round(tabH * 0.72); + const dot = Math.max(5, Math.round(windowHeader * 0.17)); + const ico = Math.max(7, Math.round(windowHeader * 0.25)); + const omniH = Math.max(12, Math.round(addrH * 0.75)); + const fs = Math.max(7, Math.round(windowHeader * 0.2)); + const pad = Math.max(6, Math.round(windowHeader * 0.2)); + const gap = Math.max(3, Math.round(windowHeader * 0.12)); + const dotGap = Math.max(2, Math.round(dot * 0.5)); + const dotsWidth = 3 * dot + 2 * dotGap; + + return ( +
+ {/* Tab bar row */} +
+ {/* Traffic lights */} +
+ + + +
+ {/* Active tab — squircle top corners */} +
+ + {title || 'New Tab'} + + + + +
+ {/* New tab + aligned with × in the tab */} + + + +
+ {/* Address bar row */} +
+ + + + + + + + + + {/* Omnibox */} +
+ + + + + + {title || ''} + +
+ + + + + +
+
+ ); +} diff --git a/apps/dashboard/components/canvas/frames/Frame3DOverlay.tsx b/apps/dashboard/components/canvas/frames/Frame3DOverlay.tsx new file mode 100644 index 0000000..ab7097e --- /dev/null +++ b/apps/dashboard/components/canvas/frames/Frame3DOverlay.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { SafariToolbar, ChromeToolbar } from './BrowserToolbar'; + +export interface FrameConfig { + enabled: boolean; + type: 'none' | 'arc-light' | 'arc-dark' | 'macos-light' | 'macos-dark' | 'windows-light' | 'windows-dark' | 'photograph' | 'glass-light' | 'glass-dark' | 'outline-light' | 'border-light' | 'border-dark'; + width: number; + color: string; + padding?: number; + title?: string; + opacity?: number; +} + +/** + * Returns CSS styles for frames to be applied directly to the image element. + * This ensures the border wraps the image properly with overflow:hidden. + */ +export function getFrameImageStyle( + frame: FrameConfig, + screenshotRadius: number +): React.CSSProperties | null { + const arcBorderWidth = frame.width || 8; + + switch (frame.type) { + case 'arc-light': { + const lightOpacity = frame.opacity ?? 0.5; + return { + border: `${arcBorderWidth}px solid rgba(255, 255, 255, ${lightOpacity})`, + borderRadius: `${screenshotRadius}px`, + overflow: 'hidden', + boxSizing: 'border-box', + }; + } + + case 'arc-dark': { + const darkOpacity = frame.opacity ?? 0.7; + return { + border: `${arcBorderWidth}px solid rgba(0, 0, 0, ${darkOpacity})`, + borderRadius: `${screenshotRadius}px`, + overflow: 'hidden', + boxSizing: 'border-box', + }; + } + + case 'photograph': + // Polaroid style: 8px top/sides, 24px bottom + return { + borderWidth: '8px 8px 24px 8px', + borderStyle: 'solid', + borderColor: 'white', + borderRadius: '8px', + overflow: 'hidden', + boxSizing: 'border-box', + }; + + default: + return null; + } +} + +interface Frame3DOverlayProps { + frame: FrameConfig; + showFrame: boolean; + framedW: number; + framedH: number; + frameOffset: number; + windowPadding: number; + windowHeader: number; + eclipseBorder: number; + imageScaledW: number; + imageScaledH: number; + screenshotRadius: number; +} + +export function Frame3DOverlay({ + frame, + showFrame, + windowHeader, + screenshotRadius, +}: Frame3DOverlayProps) { + if (!showFrame || frame.type === 'none') { + return null; + } + + const isDark = frame.type.includes('dark'); + + const borderWidth = frame.width || 8; + + // For arc frames, the border should wrap tightly around the image + // The outer radius = inner radius + border width + const arcOuterRadius = screenshotRadius + borderWidth; + + switch (frame.type) { + case 'arc-light': + case 'arc-dark': + case 'photograph': + // These frames return null here - the border is applied directly to the image + // via the getFrameImageStyle() helper used in Perspective3DOverlay + return null; + + case 'macos-light': + case 'macos-dark': + return ; + + case 'windows-light': + case 'windows-dark': + return ; + + default: + return null; + } +} diff --git a/apps/dashboard/components/canvas/hooks/useImageLoading.ts b/apps/dashboard/components/canvas/hooks/useImageLoading.ts new file mode 100644 index 0000000..cc4d069 --- /dev/null +++ b/apps/dashboard/components/canvas/hooks/useImageLoading.ts @@ -0,0 +1,256 @@ +import { useEffect, useState } from "react"; +import type { BackgroundConfig } from "@/lib/constants/backgrounds"; +import { getR2ImageUrl } from "@/lib/r2"; +import { backgroundPaths } from "@/lib/r2/r2-backgrounds"; +import { isOverlayPath } from "@/lib/r2/r2-overlays"; +import type { ImageOverlay } from "@/lib/store"; + +const MAX_RETRIES = 2; +const RETRY_DELAY = 800; // ms + +function loadImageWithRetry( + url: string, + retries = 0, + signal?: AbortSignal +): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException("Aborted", "AbortError")); + return; + } + + const img = new window.Image(); + img.crossOrigin = "anonymous"; + img.onload = () => resolve(img); + img.onerror = () => { + if (signal?.aborted) { + reject(new DOMException("Aborted", "AbortError")); + return; + } + if (retries < MAX_RETRIES) { + // Retry with cache-busting param to bypass cached 404 responses + setTimeout(() => { + const bustUrl = url.includes("?") + ? `${url}&_r=${Date.now()}` + : `${url}?_r=${Date.now()}`; + loadImageWithRetry(bustUrl, retries + 1, signal) + .then(resolve) + .catch(reject); + }, RETRY_DELAY); + } else { + reject( + new Error( + `Failed to load image after ${MAX_RETRIES + 1} attempts: ${url}` + ) + ); + } + }; + img.src = url; + }); +} + +export function useBackgroundImage( + backgroundConfig: BackgroundConfig, + containerWidth: number, + containerHeight: number +) { + const [bgImage, setBgImage] = useState(null); + + useEffect(() => { + const abortController = new AbortController(); + + if (backgroundConfig.type === "image" && backgroundConfig.value) { + const imageValue = backgroundConfig.value as string; + + const isValidImageValue = + imageValue.startsWith("http") || + imageValue.startsWith("blob:") || + imageValue.startsWith("data:") || + (typeof imageValue === "string" && !imageValue.includes("_gradient")); + + if (!isValidImageValue) { + setBgImage(null); + return; + } + + let imageUrl = imageValue; + if ( + typeof imageUrl === "string" && + !imageUrl.startsWith("http") && + !imageUrl.startsWith("blob:") && + !imageUrl.startsWith("data:") && + !imageUrl.startsWith("/") + ) { + if (backgroundPaths.includes(imageUrl)) { + imageUrl = getR2ImageUrl({ src: imageUrl }); + } else { + setBgImage(null); + return; + } + } + + // Determine if this is an R2 asset (eligible for retry on 404) + const isR2Asset = imageUrl.startsWith("/r2-assets/"); + + if (isR2Asset) { + loadImageWithRetry(imageUrl, 0, abortController.signal) + .then((img) => { + if (!abortController.signal.aborted) { + setBgImage(img); + } + }) + .catch((err) => { + if (err.name !== "AbortError") { + console.error( + "Failed to load background image:", + backgroundConfig.value + ); + setBgImage(null); + } + }); + } else { + const img = new window.Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { + if (!abortController.signal.aborted) { + setBgImage(img); + } + }; + img.onerror = () => { + console.error( + "Failed to load background image:", + backgroundConfig.value + ); + setBgImage(null); + }; + img.src = imageUrl; + } + } else { + setBgImage(null); + } + + return () => { + abortController.abort(); + }; + }, [backgroundConfig, containerWidth, containerHeight]); + + return bgImage; +} + +export function useOverlayImages(imageOverlays: ImageOverlay[]) { + const [loadedOverlayImages, setLoadedOverlayImages] = useState< + Record + >({}); + + useEffect(() => { + // Create AbortController for cleanup + const abortController = new AbortController(); + + const loadOverlays = async () => { + const visibleOverlays = imageOverlays.filter( + (overlay) => overlay.isVisible + ); + + if (visibleOverlays.length === 0) { + setLoadedOverlayImages({}); + return; + } + + // Create loading promises for all overlays in parallel + const loadPromises = visibleOverlays.map((overlay) => { + return new Promise<{ id: string; img: HTMLImageElement } | null>( + (resolve) => { + // Check if aborted before starting + if (abortController.signal.aborted) { + resolve(null); + return; + } + + const isR2Overlay = + isOverlayPath(overlay.src) || + (typeof overlay.src === "string" && + overlay.src.startsWith("overlays/")); + + const imageUrl = + isR2Overlay && !overlay.isCustom + ? getR2ImageUrl({ src: overlay.src }) + : overlay.src; + + const isR2Asset = imageUrl.startsWith("/r2-assets/"); + + if (isR2Asset) { + loadImageWithRetry(imageUrl, 0, abortController.signal) + .then((img) => { + if (abortController.signal.aborted) { + resolve(null); + } else { + resolve({ id: overlay.id, img }); + } + }) + .catch((err) => { + if (err.name !== "AbortError") { + console.error( + `Failed to load overlay image for ${overlay.id}` + ); + } + resolve(null); + }); + return; + } + + const img = new window.Image(); + // Don't set crossOrigin for blob/data URLs — they're same-origin + // and setting it can cause loading failures + const isBlobOrData = + imageUrl.startsWith("blob:") || imageUrl.startsWith("data:"); + if (!isBlobOrData) { + img.crossOrigin = "anonymous"; + } + + img.onload = () => { + if (abortController.signal.aborted) { + resolve(null); + } else { + resolve({ id: overlay.id, img }); + } + }; + + img.onerror = () => { + console.error(`Failed to load overlay image for ${overlay.id}`); + resolve(null); + }; + + img.src = imageUrl; + } + ); + }); + + // Load all overlays in parallel + const results = await Promise.allSettled(loadPromises); + + // Check if still mounted (not aborted) + if (abortController.signal.aborted) { + return; + } + + // Collect successful loads + const loadedImages: Record = {}; + for (const result of results) { + if (result.status === "fulfilled" && result.value) { + loadedImages[result.value.id] = result.value.img; + } + } + + setLoadedOverlayImages(loadedImages); + }; + + loadOverlays(); + + // Cleanup: abort ongoing loads when dependencies change + return () => { + abortController.abort(); + }; + }, [imageOverlays]); + + return loadedOverlayImages; +} diff --git a/apps/dashboard/components/canvas/html/HTMLBackgroundLayer.tsx b/apps/dashboard/components/canvas/html/HTMLBackgroundLayer.tsx new file mode 100644 index 0000000..0097b17 --- /dev/null +++ b/apps/dashboard/components/canvas/html/HTMLBackgroundLayer.tsx @@ -0,0 +1,292 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { + type BackgroundConfig, + getBackgroundCSS, +} from "@/lib/constants/backgrounds"; + +interface HTMLBackgroundLayerProps { + backgroundBlur: number; + backgroundBorderRadius: number; + backgroundConfig: BackgroundConfig; + backgroundNoise: number; + height: number; + noiseTexture: HTMLCanvasElement | null; + width: number; +} + +const TRANSITION_DURATION = 400; // ms +const MAX_RETRIES = 2; +const RETRY_DELAY = 800; + +/** + * Preload an image URL with retry + cache-busting for R2 404s. + * Returns the successfully loaded URL (may have cache-bust param). + */ +function preloadImage( + url: string, + retries = 0, + signal?: AbortSignal +): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException("Aborted", "AbortError")); + return; + } + const img = new window.Image(); + img.onload = () => resolve(url); + img.onerror = () => { + if (signal?.aborted) { + reject(new DOMException("Aborted", "AbortError")); + return; + } + if (retries < MAX_RETRIES) { + setTimeout(() => { + const bustUrl = url.includes("?") + ? `${url}&_r=${Date.now()}` + : `${url}?_r=${Date.now()}`; + preloadImage(bustUrl, retries + 1, signal) + .then(resolve) + .catch(reject); + }, RETRY_DELAY); + } else { + reject(new Error(`Failed to load: ${url}`)); + } + }; + img.src = url; + }); +} + +/** + * Extract image URL from a CSSProperties backgroundImage value. + */ +function extractImageUrl(style: React.CSSProperties): string | null { + const bg = style.backgroundImage; + if (!bg || typeof bg !== "string") { + return null; + } + const match = bg.match(/url\(([^)]+)\)/); + if (!match) { + return null; + } + return match[1].replace(/['"]/g, ""); +} + +/** + * HTML/CSS-based background layer that replaces Konva BackgroundLayer. + * Renders backgrounds (solid, gradient, image) with blur and noise support. + * Uses a crossfade effect for smooth transitions between backgrounds. + */ +export function HTMLBackgroundLayer({ + backgroundConfig, + backgroundBlur, + backgroundBorderRadius, + width, + height, + noiseTexture, + backgroundNoise, +}: HTMLBackgroundLayerProps) { + const backgroundStyle = useMemo( + () => getBackgroundCSS(backgroundConfig), + [backgroundConfig] + ); + + // Track which layer (A or B) is active for crossfade + const [activeLayer, setActiveLayer] = useState<"a" | "b">("a"); + const [layerAStyle, setLayerAStyle] = + useState(backgroundStyle); + const [layerBStyle, setLayerBStyle] = + useState(backgroundStyle); + const [showTransition, setShowTransition] = useState(false); + const prevConfigRef = useRef(backgroundConfig); + const timeoutRef = useRef>(undefined); + const isFirstRender = useRef(true); + + useEffect(() => { + const abortController = new AbortController(); + + // Skip crossfade on initial render + if (isFirstRender.current) { + isFirstRender.current = false; + // For initial image backgrounds, preload to handle 404s + if (backgroundConfig.type === "image") { + const url = extractImageUrl(backgroundStyle); + if (url && url.startsWith("/r2-assets/")) { + preloadImage(url, 0, abortController.signal) + .then((loadedUrl) => { + if (!abortController.signal.aborted) { + const style = { + ...backgroundStyle, + backgroundImage: `url(${loadedUrl})`, + }; + setLayerAStyle(style); + setLayerBStyle(style); + } + }) + .catch(() => { + // Use original URL as fallback + }); + return () => abortController.abort(); + } + } + setLayerAStyle(backgroundStyle); + setLayerBStyle(backgroundStyle); + return; + } + + const prev = prevConfigRef.current; + const changed = + prev.type !== backgroundConfig.type || + prev.value !== backgroundConfig.value; + + if (changed) { + prevConfigRef.current = backgroundConfig; + + const applyNewBackground = (style: React.CSSProperties) => { + if (abortController.signal.aborted) { + return; + } + if (activeLayer === "a") { + setLayerBStyle(style); + setShowTransition(true); + requestAnimationFrame(() => { + if (!abortController.signal.aborted) { + setActiveLayer("b"); + } + }); + } else { + setLayerAStyle(style); + setShowTransition(true); + requestAnimationFrame(() => { + if (!abortController.signal.aborted) { + setActiveLayer("a"); + } + }); + } + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + setShowTransition(false); + }, TRANSITION_DURATION + 50); + }; + + // For image backgrounds, preload before transitioning + if (backgroundConfig.type === "image") { + const url = extractImageUrl(backgroundStyle); + if (url && url.startsWith("/r2-assets/")) { + preloadImage(url, 0, abortController.signal) + .then((loadedUrl) => { + applyNewBackground({ + ...backgroundStyle, + backgroundImage: `url(${loadedUrl})`, + }); + }) + .catch((err) => { + if (err.name !== "AbortError") { + // Still apply even if preload fails, so UI isn't stuck + applyNewBackground(backgroundStyle); + } + }); + return () => { + abortController.abort(); + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + } + } + + applyNewBackground(backgroundStyle); + } else { + // Opacity-only change, update active layer style directly + if (activeLayer === "a") { + setLayerAStyle(backgroundStyle); + } else { + setLayerBStyle(backgroundStyle); + } + } + + return () => { + abortController.abort(); + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, [backgroundConfig, backgroundStyle, activeLayer]); + + const noiseDataUrl = useMemo(() => { + if (!noiseTexture || backgroundNoise <= 0) { + return null; + } + try { + return noiseTexture.toDataURL("image/png"); + } catch { + return null; + } + }, [noiseTexture, backgroundNoise]); + + const sharedStyle: React.CSSProperties = { + position: "absolute", + inset: 0, + width: `${width}px`, + height: `${height}px`, + borderRadius: `${backgroundBorderRadius}px`, + overflow: "hidden", + filter: backgroundBlur > 0 ? `blur(${backgroundBlur}px)` : undefined, + }; + + const transitionStyle = showTransition + ? `opacity ${TRANSITION_DURATION}ms ease-in-out` + : undefined; + + return ( + <> + {/* Layer A */} +
+ + {/* Layer B */} +
+ + {/* Noise overlay */} + {noiseDataUrl && backgroundNoise > 0 && ( +
+ )} + + ); +} diff --git a/apps/dashboard/components/canvas/html/HTMLBlurRegionLayer.tsx b/apps/dashboard/components/canvas/html/HTMLBlurRegionLayer.tsx new file mode 100644 index 0000000..a6c1cb1 --- /dev/null +++ b/apps/dashboard/components/canvas/html/HTMLBlurRegionLayer.tsx @@ -0,0 +1,317 @@ +'use client'; + +import { useRef, useState, useCallback, useEffect } from 'react'; +import type { BlurRegion } from '@/lib/store'; + +interface HTMLBlurRegionLayerProps { + blurRegions: BlurRegion[]; + selectedBlurId: string | null; + setSelectedBlurId: (id: string | null) => void; + updateBlurRegion: (id: string, updates: Partial) => void; + removeBlurRegion: (id: string) => void; +} + +function DraggableBlurRegion({ + region, + isSelected, + onSelect, + onUpdate, + onRemove, +}: { + region: BlurRegion; + isSelected: boolean; + onSelect: () => void; + onUpdate: (updates: Partial) => void; + onRemove: () => void; +}) { + const ref = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [isResizing, setIsResizing] = useState(false); + const dragStartRef = useRef({ mouseX: 0, mouseY: 0, posX: 0, posY: 0 }); + const resizeStartRef = useRef<{ + mouseX: number; + mouseY: number; + width: number; + height: number; + posX: number; + posY: number; + handle: string; + } | null>(null); + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + if (isResizing) return; + e.preventDefault(); + e.stopPropagation(); + (e.target as Element).setPointerCapture(e.pointerId); + setIsDragging(true); + dragStartRef.current = { + mouseX: e.clientX, + mouseY: e.clientY, + posX: region.position.x, + posY: region.position.y, + }; + onSelect(); + }, + [isResizing, region.position.x, region.position.y, onSelect] + ); + + const handleResizePointerDown = useCallback( + (e: React.PointerEvent, handle: string) => { + e.preventDefault(); + e.stopPropagation(); + setIsResizing(true); + resizeStartRef.current = { + mouseX: e.clientX, + mouseY: e.clientY, + width: region.size.width, + height: region.size.height, + posX: region.position.x, + posY: region.position.y, + handle, + }; + onSelect(); + }, + [region.size.width, region.size.height, region.position.x, region.position.y, onSelect] + ); + + useEffect(() => { + if (!isDragging) return; + + const handleMove = (e: PointerEvent) => { + const dx = e.clientX - dragStartRef.current.mouseX; + const dy = e.clientY - dragStartRef.current.mouseY; + onUpdate({ + position: { + x: dragStartRef.current.posX + dx, + y: dragStartRef.current.posY + dy, + }, + }); + }; + + const handleUp = () => setIsDragging(false); + + window.addEventListener('pointermove', handleMove); + window.addEventListener('pointerup', handleUp); + return () => { + window.removeEventListener('pointermove', handleMove); + window.removeEventListener('pointerup', handleUp); + }; + }, [isDragging, onUpdate]); + + useEffect(() => { + if (!isResizing) return; + + const handleMove = (e: PointerEvent) => { + const start = resizeStartRef.current; + if (!start) return; + + const dx = e.clientX - start.mouseX; + const dy = e.clientY - start.mouseY; + + let newW = start.width; + let newH = start.height; + let newX = start.posX; + let newY = start.posY; + + if (start.handle.includes('r')) newW = Math.max(30, start.width + dx); + if (start.handle.includes('l')) { + newW = Math.max(30, start.width - dx); + newX = start.posX + dx; + } + if (start.handle.includes('b')) newH = Math.max(30, start.height + dy); + if (start.handle.includes('t')) { + newH = Math.max(30, start.height - dy); + newY = start.posY + dy; + } + + onUpdate({ + position: { x: newX, y: newY }, + size: { width: newW, height: newH }, + }); + }; + + const handleUp = () => { + setIsResizing(false); + resizeStartRef.current = null; + }; + + window.addEventListener('pointermove', handleMove); + window.addEventListener('pointerup', handleUp); + return () => { + window.removeEventListener('pointermove', handleMove); + window.removeEventListener('pointerup', handleUp); + }; + }, [isResizing, onUpdate]); + + useEffect(() => { + if (!isSelected) return; + const handleKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return; + if (e.key === 'Delete' || e.key === 'Backspace') { + e.preventDefault(); + onRemove(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isSelected, onRemove]); + + if (!region.isVisible) return null; + + return ( +
+ {/* Resize handles + delete button */} + {isSelected && ( + <> + {/* Delete button */} +
{ + e.preventDefault(); + e.stopPropagation(); + onRemove(); + }} + style={{ + position: 'absolute', + top: '-12px', + right: '-12px', + width: '24px', + height: '24px', + borderRadius: '50%', + backgroundColor: 'hsl(0, 84%, 60%)', + border: '2px solid white', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + zIndex: 30, + pointerEvents: 'auto', + boxShadow: '0 2px 4px rgba(0,0,0,0.25)', + }} + > + + + +
+ + {/* Corner resize handles */} + {(['tl', 'tr', 'bl', 'br'] as const).map((handle) => { + const isTop = handle.includes('t'); + const isLeft = handle.includes('l'); + + let cursor = 'default'; + if (handle === 'tl' || handle === 'br') cursor = 'nwse-resize'; + else if (handle === 'tr' || handle === 'bl') cursor = 'nesw-resize'; + + return ( +
handleResizePointerDown(e, handle)} + style={{ + position: 'absolute', + width: '14px', + height: '14px', + backgroundColor: 'white', + border: '2px solid hsl(var(--primary))', + borderRadius: '50%', + boxShadow: '0 1px 3px rgba(0,0,0,0.15)', + top: isTop ? '-7px' : undefined, + bottom: !isTop ? '-7px' : undefined, + left: isLeft ? '-7px' : undefined, + right: !isLeft ? '-7px' : undefined, + cursor, + zIndex: 20, + pointerEvents: 'auto', + }} + /> + ); + })} + + {/* Edge resize handles */} + {(['t', 'r', 'b', 'l'] as const).map((handle) => { + const isHorizontal = handle === 't' || handle === 'b'; + return ( +
handleResizePointerDown(e, handle)} + style={{ + position: 'absolute', + width: isHorizontal ? '24px' : '10px', + height: isHorizontal ? '10px' : '24px', + backgroundColor: 'white', + border: '2px solid hsl(var(--primary))', + borderRadius: '5px', + boxShadow: '0 1px 3px rgba(0,0,0,0.15)', + top: handle === 't' ? '-5px' : handle === 'b' ? undefined : '50%', + bottom: handle === 'b' ? '-5px' : undefined, + left: handle === 'l' ? '-5px' : handle === 'r' ? undefined : '50%', + right: handle === 'r' ? '-5px' : undefined, + transform: handle === 't' || handle === 'b' ? 'translateX(-50%)' : 'translateY(-50%)', + cursor: isHorizontal ? 'ns-resize' : 'ew-resize', + zIndex: 20, + pointerEvents: 'auto', + }} + /> + ); + })} + + )} +
+ ); +} + +export function HTMLBlurRegionLayer({ + blurRegions, + selectedBlurId, + setSelectedBlurId, + updateBlurRegion, + removeBlurRegion, +}: HTMLBlurRegionLayerProps) { + return ( +
+ {blurRegions.map((region) => ( + setSelectedBlurId(region.id)} + onUpdate={(updates) => updateBlurRegion(region.id, updates)} + onRemove={() => { + removeBlurRegion(region.id); + setSelectedBlurId(null); + }} + /> + ))} +
+ ); +} diff --git a/apps/dashboard/components/canvas/html/HTMLCanvasRenderer.tsx b/apps/dashboard/components/canvas/html/HTMLCanvasRenderer.tsx new file mode 100644 index 0000000..f1cc053 --- /dev/null +++ b/apps/dashboard/components/canvas/html/HTMLCanvasRenderer.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { forwardRef, type ReactNode, type CSSProperties } from 'react'; + +interface HTMLCanvasRendererProps { + width: number; + height: number; + borderRadius?: number; + children: ReactNode; + className?: string; + style?: CSSProperties; + onClick?: (e: React.MouseEvent) => void; + onPointerDown?: (e: React.PointerEvent) => void; +} + +/** + * HTML-based canvas container that replaces Konva Stage. + * Uses pure CSS for rendering with proper overflow handling. + */ +export const HTMLCanvasRenderer = forwardRef( + function HTMLCanvasRenderer( + { width, height, borderRadius = 0, children, className, style, onClick, onPointerDown }, + ref + ) { + return ( +
+ {children} +
+ ); + } +); diff --git a/apps/dashboard/components/canvas/html/HTMLGridLayer.tsx b/apps/dashboard/components/canvas/html/HTMLGridLayer.tsx new file mode 100644 index 0000000..595432c --- /dev/null +++ b/apps/dashboard/components/canvas/html/HTMLGridLayer.tsx @@ -0,0 +1,31 @@ +import { memo } from 'react' + +interface HTMLGridLayerProps { + canvasW: number + canvasH: number + gridSize?: number +} + +export const HTMLGridLayer = memo(function HTMLGridLayer({ + canvasW, + canvasH, + gridSize = 50, +}: HTMLGridLayerProps) { + return ( +
+ ) +}) diff --git a/apps/dashboard/components/canvas/html/HTMLImageOverlayLayer.tsx b/apps/dashboard/components/canvas/html/HTMLImageOverlayLayer.tsx new file mode 100644 index 0000000..a5e04b9 --- /dev/null +++ b/apps/dashboard/components/canvas/html/HTMLImageOverlayLayer.tsx @@ -0,0 +1,373 @@ +/** biome-ignore-all lint/correctness/useImageSize: */ +/** biome-ignore-all lint/performance/noImgElement: */ +"use client"; + +import { cn } from "@castfy/ui/lib/utils"; +import { + CopyIcon, + LayersMinusIcon, + LayersPlusIcon, + Trash2Icon, +} from "lucide-react"; +import { useCallback, useMemo, useRef, useState } from "react"; +import Moveable from "react-moveable"; +import type { ImageOverlay } from "@/lib/store"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +interface HTMLImageOverlayLayerProps { + imageOverlays: ImageOverlay[]; + loadedOverlayImages: Record; + onDelete?: (id: string) => void; + onDuplicate?: (id: string) => void; + selectedOverlayId: string | null; + setIsMainImageSelected: (selected: boolean) => void; + setSelectedOverlayId: (id: string | null) => void; + setSelectedTextId: (id: string | null) => void; + updateImageOverlay: (id: string, updates: Partial) => void; + zIndex?: number; +} + +// ── Single overlay element ─────────────────────────────────────────────────── + +function OverlayElement({ + overlay, + overlayImg, + onSelect, + elRef, +}: { + overlay: ImageOverlay; + overlayImg: HTMLImageElement; + onSelect: () => void; + elRef: (el: HTMLDivElement | null) => void; +}) { + const isShadow = useMemo( + () => + typeof overlay.src === "string" && overlay.src.includes("overlay-shadow"), + [overlay.src] + ); + + if (!overlay.isVisible) { + return null; + } + + if (isShadow) { + return ( +
+ {/** biome-ignore lint/correctness/useImageSize: */} + Shadow overlay +
+ ); + } + + const flipTransform = [ + overlay.flipX ? "scaleX(-1)" : "", + overlay.flipY ? "scaleY(-1)" : "", + ] + .filter(Boolean) + .join(" "); + + return ( +
{ + e.stopPropagation(); + onSelect(); + }} + ref={elRef} + style={{ + position: "absolute", + left: `${overlay.position.x - overlay.size / 2}px`, + top: `${overlay.position.y - overlay.size / 2}px`, + width: `${overlay.size}px`, + height: `${overlay.size}px`, + transform: `rotate(${overlay.rotation}deg)`, + opacity: overlay.opacity, + filter: (overlay.blur ?? 0) > 0 ? `blur(${overlay.blur}px)` : undefined, + cursor: "grab", + userSelect: "none", + pointerEvents: "auto", + }} + > + Overlay +
+ ); +} + +// ── Context toolbar (minimal, bottom-anchored) ────────────────────────────── + +function ContextToolbar({ + overlay, + onUpdate, + onDuplicate, + onDelete, +}: { + overlay: ImageOverlay; + onUpdate: (updates: Partial) => void; + onDuplicate: () => void; + onDelete: () => void; +}) { + const isFront = (overlay.layer || "front") === "front"; + + return ( +
e.stopPropagation()} + style={{ pointerEvents: "auto" }} + > + + +
+ +
+ ); +} + +// ── Main layer component ───────────────────────────────────────────────────── + +export function HTMLImageOverlayLayer({ + imageOverlays, + loadedOverlayImages, + selectedOverlayId, + setSelectedOverlayId, + setIsMainImageSelected, + setSelectedTextId, + updateImageOverlay, + onDuplicate, + onDelete, + zIndex = 200, +}: HTMLImageOverlayLayerProps) { + const overlayRefs = useRef>(new Map()); + const [interacting, setInteracting] = useState(false); + + const setOverlayRef = useCallback( + (id: string) => (el: HTMLDivElement | null) => { + if (el) { + overlayRefs.current.set(id, el); + } else { + overlayRefs.current.delete(id); + } + }, + [] + ); + + const handleSelect = useCallback( + (id: string) => { + setSelectedOverlayId(id); + setIsMainImageSelected(false); + setSelectedTextId(null); + }, + [setSelectedOverlayId, setIsMainImageSelected, setSelectedTextId] + ); + + const selectedOverlay = selectedOverlayId + ? imageOverlays.find((o) => o.id === selectedOverlayId) + : null; + + const selectedEl = selectedOverlayId + ? (overlayRefs.current.get(selectedOverlayId) ?? null) + : null; + const isShadow = selectedOverlay?.src.includes("overlay-shadow"); + + return ( +
+ {imageOverlays.map((overlay) => { + if (!overlay.isVisible) { + return null; + } + const overlayImg = loadedOverlayImages[overlay.id]; + if (!overlayImg) { + return null; + } + + return ( + handleSelect(overlay.id)} + overlay={overlay} + overlayImg={overlayImg} + /> + ); + })} + + {/* Moveable + Context toolbar for selected overlay */} + {selectedOverlay && selectedEl && !isShadow && ( + <> + { + target.style.left = `${left}px`; + target.style.top = `${top}px`; + }} + onDragEnd={({ target }) => { + setInteracting(false); + const left = Number.parseFloat(target.style.left); + const top = Number.parseFloat(target.style.top); + const w = Number.parseFloat(target.style.width); + const h = Number.parseFloat(target.style.height); + updateImageOverlay(selectedOverlay.id, { + position: { x: left + w / 2, y: top + h / 2 }, + }); + }} + onDragStart={() => setInteracting(true)} + onResize={({ target, width, height, drag }) => { + target.style.width = `${width}px`; + target.style.height = `${height}px`; + target.style.left = `${drag.left}px`; + target.style.top = `${drag.top}px`; + }} + onResizeEnd={({ target }) => { + setInteracting(false); + const w = Number.parseFloat(target.style.width); + const h = Number.parseFloat(target.style.height); + const left = Number.parseFloat(target.style.left); + const top = Number.parseFloat(target.style.top); + updateImageOverlay(selectedOverlay.id, { + size: Math.round(Math.max(w, h)), + position: { x: left + w / 2, y: top + h / 2 }, + }); + }} + onResizeStart={() => setInteracting(true)} + onRotate={({ target, transform }) => { + target.style.transform = transform; + }} + onRotateEnd={({ target }) => { + setInteracting(false); + const match = target.style.transform.match( + /rotate\(([-\d.]+)deg\)/ + ); + if (match) { + let deg = Number.parseFloat(match[1]) % 360; + if (deg > 180) { + deg -= 360; + } + if (deg < -180) { + deg += 360; + } + updateImageOverlay(selectedOverlay.id, { + rotation: Math.round(deg), + }); + } + }} + onRotateStart={() => setInteracting(true)} + origin={false} + renderDirections={["nw", "ne", "sw", "se"]} + resizable={true} + rotatable={true} + rotationPosition={"top"} + target={selectedEl} + throttleDrag={0} + throttleResize={0} + throttleRotate={0} + /> + + {/* Minimal context toolbar below the selected overlay */} + {!interacting && ( +
+ onDelete?.(selectedOverlay.id)} + onDuplicate={() => onDuplicate?.(selectedOverlay.id)} + onUpdate={(updates) => + updateImageOverlay(selectedOverlay.id, updates) + } + overlay={selectedOverlay} + /> +
+ )} + + )} +
+ ); +} diff --git a/apps/dashboard/components/canvas/html/HTMLMainImageLayer.tsx b/apps/dashboard/components/canvas/html/HTMLMainImageLayer.tsx new file mode 100644 index 0000000..c7365ea --- /dev/null +++ b/apps/dashboard/components/canvas/html/HTMLMainImageLayer.tsx @@ -0,0 +1,850 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { type ImageFilters, useImageStore } from "@/lib/store"; +import { ChromeToolbar, SafariToolbar } from "../frames/BrowserToolbar"; +import type { ShadowConfig } from "../utils/shadow-utils"; + +export interface FrameConfig { + color: string; + enabled: boolean; + opacity?: number; + padding?: number; + title?: string; + type: + | "none" + | "arc-light" + | "arc-dark" + | "macos-light" + | "macos-dark" + | "windows-light" + | "windows-dark" + | "photograph" + | "glass-light" + | "glass-dark" + | "outline-light" + | "border-light" + | "border-dark"; + width: number; +} + +interface HTMLMainImageLayerProps { + canvasH: number; + canvasW: number; + frame: FrameConfig; + framedH: number; + framedW: number; + frameOffset: number; + image: HTMLImageElement; + imageFilters?: ImageFilters; + imageOpacity: number; + imageScaledH: number; + imageScaledW: number; + isMainImageSelected: boolean; + onDragStateChange?: (isDragging: boolean) => void; + onRemoveImage?: () => void; + screenshot: { + offsetX: number; + offsetY: number; + rotation: number; + radius: number; + scale: number; + }; + setIsMainImageSelected: (selected: boolean) => void; + setScreenshot: ( + updates: Partial + ) => void; + setSelectedOverlayId: (id: string | null) => void; + setSelectedTextId: (id: string | null) => void; + shadow: ShadowConfig; + showFrame: boolean; + windowHeader: number; + windowPadding: number; +} + +const SNAP_THRESHOLD = 6; + +/** + * Builds CSS filter string from imageFilters + */ +function buildImageFilter(imageFilters?: ImageFilters): string | undefined { + if (!imageFilters) { + return; + } + + const filters: string[] = []; + + if (imageFilters.brightness !== 100) { + filters.push(`brightness(${imageFilters.brightness / 100})`); + } + if (imageFilters.contrast !== 100) { + filters.push(`contrast(${imageFilters.contrast / 100})`); + } + if (imageFilters.saturate !== 100) { + filters.push(`saturate(${imageFilters.saturate / 100})`); + } + if (imageFilters.grayscale > 0) { + filters.push(`grayscale(${imageFilters.grayscale / 100})`); + } + if (imageFilters.sepia > 0) { + filters.push(`sepia(${imageFilters.sepia / 100})`); + } + if (imageFilters.hueRotate !== 0) { + filters.push(`hue-rotate(${imageFilters.hueRotate}deg)`); + } + if (imageFilters.blur > 0) { + filters.push(`blur(${imageFilters.blur}px)`); + } + if (imageFilters.invert > 0) { + filters.push(`invert(${imageFilters.invert / 100})`); + } + + return filters.length > 0 ? filters.join(" ") : undefined; +} + +/** + * Builds a CSS `filter: drop-shadow()` string. + * + * ShadowConfig fields (synced from imageShadow): + * - softness → blur radius (from imageShadow.blur) + * - offsetX/Y → shadow offset (direct from imageShadow) + * - intensity → opacity (from imageShadow.opacity) + * - color → shadow color (direct from imageShadow) + */ +function buildDropShadowFilter(shadow: ShadowConfig): string | undefined { + if (!shadow.enabled) { + return; + } + + const { softness, spread, color, intensity, offsetX, offsetY } = shadow; + + // Parse shadow color — use it directly + let r = 0, + g = 0, + b = 0; + const colorMatch = color.match(/rgba?\(([^)]+)\)/); + + if (colorMatch) { + const parts = colorMatch[1].split(",").map((s) => s.trim()); + r = Number.parseInt(parts[0]) || 0; + g = Number.parseInt(parts[1]) || 0; + b = Number.parseInt(parts[2]) || 0; + } else if (color.startsWith("#")) { + const hex = color.replace("#", ""); + r = Number.parseInt(hex.slice(0, 2), 16) || 0; + g = Number.parseInt(hex.slice(2, 4), 16) || 0; + b = Number.parseInt(hex.slice(4, 6), 16) || 0; + } + + const x = offsetX ?? 0; + const y = offsetY ?? 0; + // Blur from the blur slider; spread adds extra diffusion + const blur = softness + (spread || 0); + const opacity = Math.min(1, Math.max(0, intensity)); + + // Two-layer shadow: key shadow + soft ambient fill + return [ + `drop-shadow(${x}px ${y}px ${blur}px rgba(${r}, ${g}, ${b}, ${opacity}))`, + `drop-shadow(0px 0px ${blur * 0.5}px rgba(${r}, ${g}, ${b}, ${opacity * 0.2}))`, + ].join(" "); +} + +/** + * Builds a CSS box-shadow string for style frames. + * Unlike drop-shadow, box-shadow follows border-radius and ignores content transparency, + * so it wraps the entire frame+image uniformly. + */ +function buildBoxShadow(shadow: ShadowConfig): string | undefined { + if (!shadow.enabled) { + return; + } + + const { softness, spread, color, intensity, offsetX, offsetY } = shadow; + + let r = 0, + g = 0, + b = 0; + const colorMatch = color.match(/rgba?\(([^)]+)\)/); + + if (colorMatch) { + const parts = colorMatch[1].split(",").map((s) => s.trim()); + r = Number.parseInt(parts[0]) || 0; + g = Number.parseInt(parts[1]) || 0; + b = Number.parseInt(parts[2]) || 0; + } else if (color.startsWith("#")) { + const hex = color.replace("#", ""); + r = Number.parseInt(hex.slice(0, 2), 16) || 0; + g = Number.parseInt(hex.slice(2, 4), 16) || 0; + b = Number.parseInt(hex.slice(4, 6), 16) || 0; + } + + const x = offsetX ?? 0; + const y = offsetY ?? 0; + const blur = softness + (spread || 0); + const opacity = Math.min(1, Math.max(0, intensity)); + + return [ + `${x}px ${y}px ${blur}px rgba(${r}, ${g}, ${b}, ${opacity})`, + `0px 0px ${blur * 0.5}px rgba(${r}, ${g}, ${b}, ${opacity * 0.2})`, + ].join(", "); +} + +/** + * HTML/CSS-based main image layer that replaces Konva MainImageLayer. + * Renders the main image with frames, shadows, and filters. + */ +export function HTMLMainImageLayer({ + image, + canvasW, + canvasH, + framedW, + framedH, + frameOffset, + windowPadding, + windowHeader, + imageScaledW, + imageScaledH, + screenshot, + frame, + shadow, + showFrame, + imageOpacity, + imageFilters, + isMainImageSelected, + setIsMainImageSelected, + setSelectedOverlayId, + setSelectedTextId, + setScreenshot, + onDragStateChange, + onRemoveImage, +}: HTMLMainImageLayerProps) { + const containerRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); + const [isResizing, setIsResizing] = useState(false); + const [isRotating, setIsRotating] = useState(false); + const resizeStartRef = useRef<{ + mouseX: number; + mouseY: number; + scale: number; + handle: string; + } | null>(null); + const rotateStartRef = useRef<{ + centerX: number; + centerY: number; + startAngle: number; + startRotation: number; + } | null>(null); + + const imageFilter = useMemo( + () => buildImageFilter(imageFilters), + [imageFilters] + ); + const isStyleFrame = [ + "glass-light", + "glass-dark", + "outline-light", + "border-light", + "border-dark", + ].includes(frame.type); + // Style frames use box-shadow on the frame container; others use drop-shadow filter on the outer div + const shadowFilter = useMemo( + () => (isStyleFrame ? undefined : buildDropShadowFilter(shadow)), + [shadow, isStyleFrame] + ); + const frameBoxShadow = useMemo( + () => (isStyleFrame ? buildBoxShadow(shadow) : undefined), + [shadow, isStyleFrame] + ); + + const isDark = frame.type.includes("dark"); + const isArcFrame = frame.type === "arc-light" || frame.type === "arc-dark"; + const isMacFrame = + frame.type === "macos-light" || frame.type === "macos-dark"; + const isWinFrame = + frame.type === "windows-light" || frame.type === "windows-dark"; + const isPolaroid = frame.type === "photograph"; + + // Handle drag start + const handleMouseDown = useCallback( + (e: React.PointerEvent) => { + if (isResizing || isRotating) { + return; + } + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + onDragStateChange?.(true); + setDragStart({ + x: e.clientX - screenshot.offsetX, + y: e.clientY - screenshot.offsetY, + }); + setIsMainImageSelected(true); + setSelectedOverlayId(null); + setSelectedTextId(null); + }, + [ + isResizing, + isRotating, + screenshot.offsetX, + screenshot.offsetY, + setIsMainImageSelected, + setSelectedOverlayId, + setSelectedTextId, + onDragStateChange, + ] + ); + + // Handle resize start + const handleResizeMouseDown = useCallback( + (e: React.PointerEvent, handle: string) => { + e.preventDefault(); + e.stopPropagation(); + setIsResizing(true); + resizeStartRef.current = { + mouseX: e.clientX, + mouseY: e.clientY, + scale: useImageStore.getState().imageScale, + handle, + }; + }, + [] + ); + + // Handle drag move with snap-to-center + useEffect(() => { + if (!isDragging) { + return; + } + + const handleMouseMove = (e: MouseEvent) => { + let newOffsetX = e.clientX - dragStart.x; + let newOffsetY = e.clientY - dragStart.y; + + // Snap to center when close + if (Math.abs(newOffsetX) < SNAP_THRESHOLD) { + newOffsetX = 0; + } + if (Math.abs(newOffsetY) < SNAP_THRESHOLD) { + newOffsetY = 0; + } + + setScreenshot({ offsetX: newOffsetX, offsetY: newOffsetY }); + }; + + const handleMouseUp = () => { + setIsDragging(false); + onDragStateChange?.(false); + }; + + window.addEventListener("pointermove", handleMouseMove); + window.addEventListener("pointerup", handleMouseUp); + + return () => { + window.removeEventListener("pointermove", handleMouseMove); + window.removeEventListener("pointerup", handleMouseUp); + }; + }, [isDragging, dragStart, setScreenshot, onDragStateChange]); + + // Handle resize move + useEffect(() => { + if (!isResizing) { + return; + } + + const handleMouseMove = (e: PointerEvent) => { + const start = resizeStartRef.current; + if (!start) { + return; + } + + // Calculate diagonal movement based on handle position + const dx = e.clientX - start.mouseX; + const dy = e.clientY - start.mouseY; + + // Determine direction multiplier based on handle corner + let dirX = 1, + dirY = 1; + if (start.handle === "tl") { + dirX = -1; + dirY = -1; + } else if (start.handle === "tr") { + dirX = 1; + dirY = -1; + } else if (start.handle === "bl") { + dirX = -1; + dirY = 1; + } + // 'br' is default (1, 1) + + // Project mouse movement onto diagonal direction + const diagonal = (dx * dirX + dy * dirY) / 2; + // Sensitivity: ~1 scale unit per 2px of movement + const scaleDelta = diagonal * 0.5; + const newScale = Math.round( + Math.min(200, Math.max(10, start.scale + scaleDelta)) + ); + + useImageStore.getState().setImageScale(newScale); + }; + + const handleMouseUp = () => { + setIsResizing(false); + resizeStartRef.current = null; + }; + + window.addEventListener("pointermove", handleMouseMove); + window.addEventListener("pointerup", handleMouseUp); + + return () => { + window.removeEventListener("pointermove", handleMouseMove); + window.removeEventListener("pointerup", handleMouseUp); + }; + }, [isResizing]); + + // Handle rotate start + const handleRotateMouseDown = useCallback( + (e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsRotating(true); + + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) { + return; + } + + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + const startAngle = + Math.atan2(e.clientY - centerY, e.clientX - centerX) * (180 / Math.PI); + + rotateStartRef.current = { + centerX, + centerY, + startAngle, + startRotation: screenshot.rotation, + }; + }, + [screenshot.rotation] + ); + + // Handle rotate move + useEffect(() => { + if (!isRotating) { + return; + } + + const handleMouseMove = (e: PointerEvent) => { + const start = rotateStartRef.current; + if (!start) { + return; + } + + const currentAngle = + Math.atan2(e.clientY - start.centerY, e.clientX - start.centerX) * + (180 / Math.PI); + const delta = currentAngle - start.startAngle; + const newRotation = Math.round(start.startRotation + delta); + setScreenshot({ rotation: newRotation }); + }; + + const handleMouseUp = () => { + setIsRotating(false); + rotateStartRef.current = null; + }; + + window.addEventListener("pointermove", handleMouseMove); + window.addEventListener("pointerup", handleMouseUp); + + return () => { + window.removeEventListener("pointermove", handleMouseMove); + window.removeEventListener("pointerup", handleMouseUp); + }; + }, [isRotating, setScreenshot]); + + // Handle remove image + const handleRemoveImage = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (onRemoveImage) { + onRemoveImage(); + } else { + useImageStore.getState().clearImage(); + } + }, + [onRemoveImage] + ); + + // Calculate position + const centerX = canvasW / 2 + screenshot.offsetX; + const centerY = canvasH / 2 + screenshot.offsetY; + const left = centerX - framedW / 2; + const top = centerY - framedH / 2; + + const handleScale = screenshot.scale > 0 ? 1 / screenshot.scale : 1; + + const browserRadius = screenshot.radius; + + // Image border radius based on frame type + const getImageBorderRadius = () => { + if (isMacFrame || isWinFrame) { + // For frames with title bar, only round bottom corners + // Use slightly smaller radius to fit inside the container + const innerRadius = Math.max(0, browserRadius - windowPadding); + return `0 0 ${innerRadius}px ${innerRadius}px`; + } + return `${screenshot.radius}px`; + }; + + // Arc frame styles + const arcBorderWidth = frame.width || 8; + const arcDefaultOpacity = frame.type === "arc-light" ? 0.5 : 0.7; + const arcOpacity = frame.opacity ?? arcDefaultOpacity; + const arcBorderColor = + frame.type === "arc-light" + ? `rgba(255, 255, 255, ${arcOpacity})` + : `rgba(0, 0, 0, ${arcOpacity})`; + + // Safari toolbar — uses shared component + const renderMacOSTitleBar = () => ( + + ); + + // Chrome toolbar — uses shared component + const renderWindowsTitleBar = () => ( + + ); + + // Get frame container styles based on frame type + const getFrameContainerStyle = (): React.CSSProperties => { + const baseStyle: React.CSSProperties = { + position: "relative", + width: `${framedW}px`, + height: `${framedH}px`, + overflow: "hidden", + }; + + if (isArcFrame) { + return { + ...baseStyle, + border: `${arcBorderWidth}px solid ${arcBorderColor}`, + borderRadius: `${screenshot.radius}px`, + }; + } + + if (isMacFrame) { + return { + ...baseStyle, + backgroundColor: isDark ? "#3A3A3C" : "#F6F6F6", + borderRadius: `${browserRadius}px`, + }; + } + + if (isWinFrame) { + return { + ...baseStyle, + backgroundColor: isDark ? "#292A2D" : "#FFFFFF", + borderRadius: `${browserRadius}px`, + }; + } + + if (isPolaroid) { + return { + ...baseStyle, + backgroundColor: "white", + borderRadius: "8px", + padding: "8px 8px 24px 8px", + }; + } + + if (isStyleFrame) { + const styleConfig: Record = { + "glass-light": { bg: `rgba(255, 255, 255, ${frame.opacity ?? 0.25})` }, + "glass-dark": { bg: `rgba(0, 0, 0, ${frame.opacity ?? 0.7})` }, + "outline-light": { + bg: `rgba(255, 255, 255, ${frame.opacity ?? 0.35})`, + }, + "border-light": { bg: "rgb(255, 255, 255)" }, + "border-dark": { bg: "rgb(26, 26, 26)" }, + }; + const config = styleConfig[frame.type] || styleConfig["glass-light"]; + // Outer radius = inner radius + padding so curves are concentric (0 when no rounding) + const outerRadius = + screenshot.radius > 0 ? screenshot.radius + windowPadding : 0; + return { + ...baseStyle, + backgroundColor: config.bg, + borderRadius: `${outerRadius}px`, + boxShadow: frameBoxShadow, + }; + } + + // No frame + return { + ...baseStyle, + borderRadius: `${screenshot.radius}px`, + }; + }; + + // Get image container styles + const getImageContainerStyle = (): React.CSSProperties => { + const baseStyle: React.CSSProperties = { + position: "absolute", + width: `${imageScaledW}px`, + height: `${imageScaledH}px`, + overflow: "hidden", + }; + + if (isArcFrame) { + return { + ...baseStyle, + top: 0, + left: 0, + width: "100%", + height: "100%", + borderRadius: `${Math.max(0, screenshot.radius - arcBorderWidth)}px`, + }; + } + + if (isMacFrame) { + return { + ...baseStyle, + left: `${windowPadding}px`, + top: `${windowHeader}px`, + borderRadius: getImageBorderRadius(), + }; + } + + if (isWinFrame) { + return { + ...baseStyle, + left: `${windowPadding}px`, + top: `${windowHeader}px`, + borderRadius: getImageBorderRadius(), + }; + } + + if (isPolaroid) { + return { + ...baseStyle, + top: "8px", + left: "8px", + width: "calc(100% - 16px)", + height: "calc(100% - 32px)", + borderRadius: `${screenshot.radius}px`, + }; + } + + if (isStyleFrame) { + return { + ...baseStyle, + left: `${windowPadding}px`, + top: `${windowPadding}px`, + width: `${imageScaledW}px`, + height: `${imageScaledH}px`, + borderRadius: `${screenshot.radius}px`, + }; + } + + // No frame + return { + ...baseStyle, + top: 0, + left: 0, + width: "100%", + height: "100%", + borderRadius: `${screenshot.radius}px`, + }; + }; + + return ( +
+ {/* Frame container */} +
+ {/* macOS title bar */} + {showFrame && isMacFrame && renderMacOSTitleBar()} + + {/* Windows title bar */} + {showFrame && isWinFrame && renderWindowsTitleBar()} + + {/* Image container */} +
+ Main image +
+
+ + {/* Resize handles — visible when selected, excluded from export */} + {isMainImageSelected && ( + <> + {(["tl", "tr", "bl", "br"] as const).map((handle) => { + const isTop = handle[0] === "t"; + const isLeft = handle[1] === "l"; + const cursor = + handle === "tl" || handle === "br" + ? "nwse-resize" + : "nesw-resize"; + return ( +
handleResizeMouseDown(e, handle)} + style={{ + position: "absolute", + width: "10px", + height: "10px", + backgroundColor: "white", + border: "2px solid rgba(59, 130, 246, 0.8)", + borderRadius: "2px", + top: isTop ? "-5px" : undefined, + bottom: isTop ? undefined : "-5px", + left: isLeft ? "-5px" : undefined, + right: isLeft ? undefined : "-5px", + cursor, + zIndex: 20, + pointerEvents: "auto", + transform: `scale(${handleScale})`, + }} + /> + ); + })} + + {/* Connector line from image to rotate handle */} +
+ + {/* Rotate handle */} +
+ + + + +
+ + {/* Remove button */} +
+ + + + +
+ + )} +
+ ); +} diff --git a/apps/dashboard/components/canvas/html/HTMLNoiseLayer.tsx b/apps/dashboard/components/canvas/html/HTMLNoiseLayer.tsx new file mode 100644 index 0000000..7a8862f --- /dev/null +++ b/apps/dashboard/components/canvas/html/HTMLNoiseLayer.tsx @@ -0,0 +1,37 @@ +'use client'; + +interface HTMLNoiseLayerProps { + noiseImage: HTMLImageElement | null; + width: number; + height: number; + noiseOpacity: number; +} + +/** + * HTML/CSS-based noise layer that replaces Konva NoiseLayer. + * Renders repeating noise texture overlay. + */ +export function HTMLNoiseLayer({ + noiseImage, + width, + height, + noiseOpacity, +}: HTMLNoiseLayerProps) { + if (!noiseImage) return null; + + return ( +
+ ); +} diff --git a/apps/dashboard/components/canvas/html/HTMLPatternLayer.tsx b/apps/dashboard/components/canvas/html/HTMLPatternLayer.tsx new file mode 100644 index 0000000..69f2054 --- /dev/null +++ b/apps/dashboard/components/canvas/html/HTMLPatternLayer.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { useMemo } from 'react'; + +interface HTMLPatternLayerProps { + patternImage: HTMLCanvasElement | null; + width: number; + height: number; + patternOpacity: number; +} + +/** + * HTML/CSS-based pattern layer that replaces Konva PatternLayer. + * Renders repeating pattern backgrounds. + */ +export function HTMLPatternLayer({ + patternImage, + width, + height, + patternOpacity, +}: HTMLPatternLayerProps) { + // Convert pattern canvas to data URL + const patternDataUrl = useMemo(() => { + if (!patternImage) return null; + try { + return patternImage.toDataURL('image/png'); + } catch { + return null; + } + }, [patternImage]); + + if (!patternDataUrl) return null; + + return ( +
+ ); +} diff --git a/apps/dashboard/components/canvas/html/HTMLTextOverlayLayer.tsx b/apps/dashboard/components/canvas/html/HTMLTextOverlayLayer.tsx new file mode 100644 index 0000000..6d71ef5 --- /dev/null +++ b/apps/dashboard/components/canvas/html/HTMLTextOverlayLayer.tsx @@ -0,0 +1,165 @@ +'use client'; + +import { useRef, useState, useCallback, useEffect } from 'react'; +import { getFontCSS } from '@/lib/constants/fonts'; +import type { TextOverlay } from '@/lib/store'; + +interface HTMLTextOverlayLayerProps { + textOverlays: TextOverlay[]; + canvasW: number; + canvasH: number; + selectedTextId: string | null; + setSelectedTextId: (id: string | null) => void; + setSelectedOverlayId: (id: string | null) => void; + setIsMainImageSelected: (selected: boolean) => void; + updateTextOverlay: (id: string, updates: Partial) => void; +} + +interface DraggableTextProps { + overlay: TextOverlay; + canvasW: number; + canvasH: number; + isSelected: boolean; + onSelect: () => void; + onUpdate: (updates: Partial) => void; +} + +function DraggableText({ + overlay, + canvasW, + canvasH, + isSelected, + onSelect, + onUpdate, +}: DraggableTextProps) { + const ref = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); + const [initialPos, setInitialPos] = useState({ x: 0, y: 0 }); + + // Convert percentage position to pixels + const textX = (overlay.position.x / 100) * canvasW; + const textY = (overlay.position.y / 100) * canvasH; + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + setDragStart({ x: e.clientX, y: e.clientY }); + setInitialPos({ x: textX, y: textY }); + onSelect(); + }, [textX, textY, onSelect]); + + useEffect(() => { + if (!isDragging) return; + + const handleMouseMove = (e: PointerEvent) => { + const deltaX = e.clientX - dragStart.x; + const deltaY = e.clientY - dragStart.y; + const newX = initialPos.x + deltaX; + const newY = initialPos.y + deltaY; + + // Convert back to percentage + const newXPercent = (newX / canvasW) * 100; + const newYPercent = (newY / canvasH) * 100; + + onUpdate({ position: { x: newXPercent, y: newYPercent } }); + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + window.addEventListener('pointermove', handleMouseMove); + window.addEventListener('pointerup', handleMouseUp); + + return () => { + window.removeEventListener('pointermove', handleMouseMove); + window.removeEventListener('pointerup', handleMouseUp); + }; + }, [isDragging, dragStart, initialPos, canvasW, canvasH, onUpdate]); + + // Build text shadow CSS + const textShadow = overlay.textShadow?.enabled + ? `${overlay.textShadow.offsetX}px ${overlay.textShadow.offsetY}px ${overlay.textShadow.blur}px ${overlay.textShadow.color}` + : undefined; + + if (!overlay.isVisible) return null; + + return ( +
+ {overlay.text} +
+ ); +} + +/** + * HTML/CSS-based text overlay layer that replaces Konva TextOverlayLayer. + * Renders text overlays with drag support. + */ +export function HTMLTextOverlayLayer({ + textOverlays, + canvasW, + canvasH, + selectedTextId, + setSelectedTextId, + setSelectedOverlayId, + setIsMainImageSelected, + updateTextOverlay, +}: HTMLTextOverlayLayerProps) { + const handleSelect = useCallback((id: string) => { + setSelectedTextId(id); + setSelectedOverlayId(null); + setIsMainImageSelected(false); + }, [setSelectedTextId, setSelectedOverlayId, setIsMainImageSelected]); + + return ( +
+ {textOverlays.map((overlay) => ( + handleSelect(overlay.id)} + onUpdate={(updates) => updateTextOverlay(overlay.id, updates)} + /> + ))} +
+ ); +} diff --git a/apps/dashboard/components/canvas/html/SVGAnnotationLayer.tsx b/apps/dashboard/components/canvas/html/SVGAnnotationLayer.tsx new file mode 100644 index 0000000..20149b5 --- /dev/null +++ b/apps/dashboard/components/canvas/html/SVGAnnotationLayer.tsx @@ -0,0 +1,794 @@ +'use client'; + +import { useRef, useState, useCallback, useEffect } from 'react'; +import type { AnnotationShape, AnnotationToolType } from '@/lib/store'; + +interface SVGAnnotationLayerProps { + annotations: AnnotationShape[]; + activeAnnotationTool: AnnotationToolType | null; + selectedAnnotationId: string | null; + setSelectedAnnotationId: (id: string | null) => void; + canvasW: number; + canvasH: number; + addAnnotation: (annotation: Omit) => void; + updateAnnotation: (id: string, updates: Partial) => void; + removeAnnotation: (id: string) => void; + setActiveAnnotationTool: (tool: AnnotationToolType | null) => void; + annotationDefaults: { strokeColor: string; strokeWidth: number; fillColor: string }; + onDrawBlurRegion?: (rect: { x: number; y: number; w: number; h: number }) => void; +} + +// ── Geometry helpers ────────────────────────────────────────────────────────── + +function angleBetween(x1: number, y1: number, x2: number, y2: number) { + return Math.atan2(y2 - y1, x2 - x1); +} + +function ptDist(x1: number, y1: number, x2: number, y2: number) { + return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); +} + +/** Shorten a line so it ends `offset` pixels before (x2,y2) */ +function shortenEnd(x1: number, y1: number, x2: number, y2: number, offset: number) { + const d = ptDist(x1, y1, x2, y2); + if (d < offset) return { x: x1, y: y1 }; + const t = (d - offset) / d; + return { x: x1 + (x2 - x1) * t, y: y1 + (y2 - y1) * t }; +} + +/** Get tangent angle at the end of a quadratic bezier */ +function quadraticEndAngle( + _p0x: number, _p0y: number, + cx: number, cy: number, + p1x: number, p1y: number, +) { + return Math.atan2(p1y - cy, p1x - cx); +} + +/** Shorten a quadratic bezier's endpoint by `offset` along the end tangent */ +function shortenQuadEnd( + p0x: number, p0y: number, + ctrlX: number, ctrlY: number, + p1x: number, p1y: number, + offset: number, +) { + const angle = quadraticEndAngle(p0x, p0y, ctrlX, ctrlY, p1x, p1y); + return { + x: p1x - offset * Math.cos(angle), + y: p1y - offset * Math.sin(angle), + }; +} + +// ── Arrow head ─────────────────────────────────────────────────────────────── + +function ArrowHead({ x, y, angle, color, size, strokeW }: { + x: number; y: number; angle: number; color: string; size: number; strokeW: number; +}) { + const halfAngle = Math.PI / 7.2; + const p1x = x - size * Math.cos(angle - halfAngle); + const p1y = y - size * Math.sin(angle - halfAngle); + const p2x = x - size * Math.cos(angle + halfAngle); + const p2y = y - size * Math.sin(angle + halfAngle); + const notch = size * 0.35; + const nx = x - notch * Math.cos(angle); + const ny = y - notch * Math.sin(angle); + + return ( + + ); +} + +// ── Selection handle ───────────────────────────────────────────────────────── + +const HANDLE_STYLE: React.CSSProperties = { + pointerEvents: 'none', + filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.18))', +}; + +function Handle({ cx: x, cy: y, primary, filled }: { + cx: number; cy: number; primary: string; filled?: boolean; +}) { + return ( + + ); +} + +function DraggableHandle({ cx: x, cy: y, primary, onDrag }: { + cx: number; cy: number; primary: string; + onDrag: (e: React.PointerEvent) => void; +}) { + return ( + { + e.stopPropagation(); + onDrag(e); + }} + /> + ); +} + +// ── Delete button on selected annotation ──────────────────────────────────── + +function AnnotationDeleteButton({ x, y, onDelete }: { + x: number; y: number; onDelete: () => void; +}) { + return ( + { + e.stopPropagation(); + onDelete(); + }} + > + + + + ); +} + +function getDeleteButtonPos(a: AnnotationShape, canvasW: number, canvasH: number) { + let maxX = Math.max(a.x1, a.x2); + let minY = Math.min(a.y1, a.y2); + + if (a.type === 'curved-arrow' && a.cx !== undefined && a.cy !== undefined) { + maxX = Math.max(maxX, a.cx); + minY = Math.min(minY, a.cy); + } + + // Position top-right of bounding box, clamped within canvas + const btnX = Math.min(Math.max(maxX + 20, 24), canvasW - 24); + const btnY = Math.min(Math.max(minY - 20, 24), canvasH - 24); + return { x: btnX, y: btnY }; +} + +// ── Annotation element ─────────────────────────────────────────────────────── + +const DRAG_THRESHOLD = 3; // px before drag actually starts + +function AnnotationElement({ annotation, isSelected, isHovered, onSelect, onDragStart, onControlDrag, onEndpointDrag, onHover }: { + annotation: AnnotationShape; + isSelected: boolean; + isHovered: boolean; + onSelect: () => void; + onDragStart: (e: React.PointerEvent) => void; + onControlDrag?: (e: React.PointerEvent) => void; + onEndpointDrag?: (endpoint: 'p1' | 'p2', e: React.PointerEvent) => void; + onHover: (hovering: boolean) => void; +}) { + if (!annotation.isVisible) return null; + + const { type, x1, y1, x2, y2, cx, cy, strokeColor, strokeWidth, fillColor, opacity } = annotation; + const headSize = Math.max(14, strokeWidth * 2.8); + + // Hover glow — subtle brightness when hovering (not selected) + const hoverOpacity = isHovered && !isSelected ? Math.min(1, opacity + 0.15) : opacity; + const groupStyle: React.CSSProperties = isHovered && !isSelected + ? { filter: 'drop-shadow(0 0 3px rgba(0,0,0,0.15))' } + : {}; + + const commonProps = { + stroke: strokeColor, + strokeWidth, + opacity: hoverOpacity, + fill: fillColor === 'transparent' ? 'none' : fillColor, + style: { cursor: 'move', pointerEvents: 'auto' as const }, + onPointerDown: (e: React.PointerEvent) => { + e.stopPropagation(); + onSelect(); + onDragStart(e); + }, + }; + + const hitProps = { + stroke: 'transparent', + strokeWidth: Math.max(20, strokeWidth + 16), + fill: 'none', + style: { cursor: 'move', pointerEvents: 'auto' as const }, + onPointerDown: (e: React.PointerEvent) => { + e.stopPropagation(); + onSelect(); + onDragStart(e); + }, + onPointerEnter: () => onHover(true), + onPointerLeave: () => onHover(false), + }; + + const sel = 'hsl(var(--primary))'; + + switch (type) { + case 'arrow': { + const angle = angleBetween(x1, y1, x2, y2); + const end = shortenEnd(x1, y1, x2, y2, headSize * 0.6); + return ( + + + + + {isSelected && onEndpointDrag && ( + <> + onEndpointDrag('p1', e)} /> + onEndpointDrag('p2', e)} /> + + )} + + ); + } + case 'curved-arrow': { + const ctrlX = cx ?? (x1 + x2) / 2; + const ctrlY = cy ?? (y1 + y2) / 2 - 60; + const endAngle = quadraticEndAngle(x1, y1, ctrlX, ctrlY, x2, y2); + const end = shortenQuadEnd(x1, y1, ctrlX, ctrlY, x2, y2, headSize * 0.6); + const d = `M ${x1},${y1} Q ${ctrlX},${ctrlY} ${end.x},${end.y}`; + const hitD = `M ${x1},${y1} Q ${ctrlX},${ctrlY} ${x2},${y2}`; + return ( + + + + + {isSelected && ( + <> + + + {onEndpointDrag && ( + <> + onEndpointDrag('p1', e)} /> + onEndpointDrag('p2', e)} /> + + )} + {onControlDrag ? ( + + ) : ( + + )} + + )} + + ); + } + case 'line': { + return ( + + + + {isSelected && onEndpointDrag && ( + <> + onEndpointDrag('p1', e)} /> + onEndpointDrag('p2', e)} /> + + )} + + ); + } + case 'rectangle': { + const rx = Math.min(x1, x2); + const ry = Math.min(y1, y2); + const rw = Math.abs(x2 - x1); + const rh = Math.abs(y2 - y1); + return ( + + {/* Wider hit area for the rectangle outline */} + + + {isSelected && ( + <> + + {onEndpointDrag && ( + <> + onEndpointDrag('p1', e)} /> + onEndpointDrag('p2', e)} /> + + )} + + )} + + ); + } + case 'circle': { + const ecx = (x1 + x2) / 2; + const ecy = (y1 + y2) / 2; + const erx = Math.abs(x2 - x1) / 2; + const ery = Math.abs(y2 - y1) / 2; + return ( + + + + {isSelected && ( + <> + + {onEndpointDrag && ( + <> + onEndpointDrag('p1', e)} /> + onEndpointDrag('p2', e)} /> + + )} + + )} + + ); + } + default: + return null; + } +} + +// ── Main layer ─────────────────────────────────────────────────────────────── + +export function SVGAnnotationLayer({ + annotations, + activeAnnotationTool, + selectedAnnotationId: selectedId, + setSelectedAnnotationId: setSelectedId, + canvasW, + canvasH, + addAnnotation, + updateAnnotation, + removeAnnotation, + setActiveAnnotationTool, + annotationDefaults, + onDrawBlurRegion, +}: SVGAnnotationLayerProps) { + const svgRef = useRef(null); + const [hoveredId, setHoveredId] = useState(null); + + const [drawing, setDrawing] = useState<{ + type: AnnotationToolType; + x1: number; y1: number; x2: number; y2: number; + } | null>(null); + + // Drag with threshold: only starts moving after DRAG_THRESHOLD px + const [dragging, setDragging] = useState<{ + annotationId: string; + startX: number; startY: number; + origX1: number; origY1: number; + origX2: number; origY2: number; + origCx?: number; origCy?: number; + hasMoved: boolean; + } | null>(null); + + const [ctrlDragging, setCtrlDragging] = useState<{ + annotationId: string; + startX: number; startY: number; + origCx: number; origCy: number; + } | null>(null); + + const getSVGPoint = useCallback( + (e: React.PointerEvent | PointerEvent) => { + const svg = svgRef.current; + if (!svg) return { x: 0, y: 0 }; + const rect = svg.getBoundingClientRect(); + return { + x: ((e.clientX - rect.left) / rect.width) * canvasW, + y: ((e.clientY - rect.top) / rect.height) * canvasH, + }; + }, + [canvasW, canvasH] + ); + + // --- Drawing (uses pointer capture so events always fire on the rect) --- + const drawingRef = useRef(null); + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + if (!activeAnnotationTool) return; + (e.target as Element).setPointerCapture(e.pointerId); + const pt = getSVGPoint(e); + const d = { type: activeAnnotationTool, x1: pt.x, y1: pt.y, x2: pt.x, y2: pt.y }; + drawingRef.current = d; + setDrawing(d); + setSelectedId(null); + }, + [activeAnnotationTool, getSVGPoint] + ); + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (!drawingRef.current) return; + const pt = getSVGPoint(e); + const updated = { ...drawingRef.current, x2: pt.x, y2: pt.y }; + drawingRef.current = updated; + setDrawing(updated); + }, + [getSVGPoint] + ); + + const handlePointerUp = useCallback(() => { + const finished = drawingRef.current; + drawingRef.current = null; + setDrawing(null); + + if (!finished) return; + const { type, x1, y1, x2, y2 } = finished; + const d = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); + + if (d > 5) { + if (type === 'blur') { + onDrawBlurRegion?.({ + x: Math.min(x1, x2), + y: Math.min(y1, y2), + w: Math.abs(x2 - x1), + h: Math.abs(y2 - y1), + }); + } else { + let curveX: number | undefined; + let curveY: number | undefined; + if (type === 'curved-arrow') { + const mx = (x1 + x2) / 2; + const my = (y1 + y2) / 2; + const len = ptDist(x1, y1, x2, y2); + const offset = len * 0.3; + const angle = angleBetween(x1, y1, x2, y2); + curveX = mx - offset * Math.sin(angle); + curveY = my + offset * Math.cos(angle); + } + + addAnnotation({ + type, + x1, y1, x2, y2, + cx: curveX, + cy: curveY, + strokeColor: annotationDefaults.strokeColor, + strokeWidth: annotationDefaults.strokeWidth, + fillColor: annotationDefaults.fillColor, + opacity: 1, + isVisible: true, + }); + } + } + + setActiveAnnotationTool(null); + }, [addAnnotation, annotationDefaults, onDrawBlurRegion, setActiveAnnotationTool]); + + // --- Dragging existing annotation (with threshold) --- + const handleDragStart = useCallback( + (annotationId: string, e: React.PointerEvent) => { + if (activeAnnotationTool) return; + const annotation = annotations.find((a) => a.id === annotationId); + if (!annotation) return; + const pt = getSVGPoint(e); + setDragging({ + annotationId, + startX: pt.x, startY: pt.y, + origX1: annotation.x1, origY1: annotation.y1, + origX2: annotation.x2, origY2: annotation.y2, + origCx: annotation.cx, origCy: annotation.cy, + hasMoved: false, + }); + }, + [activeAnnotationTool, annotations, getSVGPoint] + ); + + useEffect(() => { + if (!dragging) return; + const handleMove = (e: PointerEvent) => { + const svg = svgRef.current; + if (!svg) return; + const rect = svg.getBoundingClientRect(); + const px = ((e.clientX - rect.left) / rect.width) * canvasW; + const py = ((e.clientY - rect.top) / rect.height) * canvasH; + const dx = px - dragging.startX; + const dy = py - dragging.startY; + + // Don't move until we exceed the drag threshold + if (!dragging.hasMoved && Math.sqrt(dx * dx + dy * dy) < DRAG_THRESHOLD) { + return; + } + + if (!dragging.hasMoved) { + setDragging((prev) => prev ? { ...prev, hasMoved: true } : null); + } + + const updates: Partial = { + x1: dragging.origX1 + dx, y1: dragging.origY1 + dy, + x2: dragging.origX2 + dx, y2: dragging.origY2 + dy, + }; + if (dragging.origCx !== undefined && dragging.origCy !== undefined) { + updates.cx = dragging.origCx + dx; + updates.cy = dragging.origCy + dy; + } + updateAnnotation(dragging.annotationId, updates); + }; + const handleUp = () => setDragging(null); + window.addEventListener('pointermove', handleMove); + window.addEventListener('pointerup', handleUp); + return () => { + window.removeEventListener('pointermove', handleMove); + window.removeEventListener('pointerup', handleUp); + }; + }, [dragging, canvasW, canvasH, updateAnnotation]); + + // --- Dragging curve control point --- + const handleControlDragStart = useCallback( + (annotationId: string, e: React.PointerEvent) => { + const annotation = annotations.find((a) => a.id === annotationId); + if (!annotation || annotation.cx === undefined || annotation.cy === undefined) return; + const pt = getSVGPoint(e); + setCtrlDragging({ + annotationId, + startX: pt.x, + startY: pt.y, + origCx: annotation.cx, + origCy: annotation.cy, + }); + }, + [annotations, getSVGPoint] + ); + + useEffect(() => { + if (!ctrlDragging) return; + const handleMove = (e: PointerEvent) => { + const svg = svgRef.current; + if (!svg) return; + const rect = svg.getBoundingClientRect(); + const px = ((e.clientX - rect.left) / rect.width) * canvasW; + const py = ((e.clientY - rect.top) / rect.height) * canvasH; + const dx = px - ctrlDragging.startX; + const dy = py - ctrlDragging.startY; + updateAnnotation(ctrlDragging.annotationId, { + cx: ctrlDragging.origCx + dx, + cy: ctrlDragging.origCy + dy, + }); + }; + const handleUp = () => setCtrlDragging(null); + window.addEventListener('pointermove', handleMove); + window.addEventListener('pointerup', handleUp); + return () => { + window.removeEventListener('pointermove', handleMove); + window.removeEventListener('pointerup', handleUp); + }; + }, [ctrlDragging, canvasW, canvasH, updateAnnotation]); + + // --- Dragging endpoint (resize) --- + const [endpointDragging, setEndpointDragging] = useState<{ + annotationId: string; + endpoint: 'p1' | 'p2'; + startX: number; startY: number; + origX: number; origY: number; + } | null>(null); + + const handleEndpointDragStart = useCallback( + (annotationId: string, endpoint: 'p1' | 'p2', e: React.PointerEvent) => { + const annotation = annotations.find((a) => a.id === annotationId); + if (!annotation) return; + const pt = getSVGPoint(e); + setEndpointDragging({ + annotationId, + endpoint, + startX: pt.x, + startY: pt.y, + origX: endpoint === 'p1' ? annotation.x1 : annotation.x2, + origY: endpoint === 'p1' ? annotation.y1 : annotation.y2, + }); + }, + [annotations, getSVGPoint] + ); + + useEffect(() => { + if (!endpointDragging) return; + const handleMove = (e: PointerEvent) => { + const svg = svgRef.current; + if (!svg) return; + const rect = svg.getBoundingClientRect(); + const px = ((e.clientX - rect.left) / rect.width) * canvasW; + const py = ((e.clientY - rect.top) / rect.height) * canvasH; + const dx = px - endpointDragging.startX; + const dy = py - endpointDragging.startY; + const newX = endpointDragging.origX + dx; + const newY = endpointDragging.origY + dy; + const updates: Partial = endpointDragging.endpoint === 'p1' + ? { x1: newX, y1: newY } + : { x2: newX, y2: newY }; + updateAnnotation(endpointDragging.annotationId, updates); + }; + const handleUp = () => setEndpointDragging(null); + window.addEventListener('pointermove', handleMove); + window.addEventListener('pointerup', handleUp); + return () => { + window.removeEventListener('pointermove', handleMove); + window.removeEventListener('pointerup', handleUp); + }; + }, [endpointDragging, canvasW, canvasH, updateAnnotation]); + + // --- Keyboard --- + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return; + if ((e.key === 'Delete' || e.key === 'Backspace') && selectedId) { + e.preventDefault(); + removeAnnotation(selectedId); + setSelectedId(null); + } + if (e.key === 'Escape') { + if (activeAnnotationTool) { + setActiveAnnotationTool(null); + setDrawing(null); + } else if (selectedId) { + setSelectedId(null); + } + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [selectedId, activeAnnotationTool, removeAnnotation, setActiveAnnotationTool]); + + const isToolActive = !!activeAnnotationTool; + + // Drawing preview + const renderDrawingPreview = () => { + if (!drawing) return null; + + if (drawing.type === 'blur') { + const rx = Math.min(drawing.x1, drawing.x2); + const ry = Math.min(drawing.y1, drawing.y2); + const rw = Math.abs(drawing.x2 - drawing.x1); + const rh = Math.abs(drawing.y2 - drawing.y1); + return ( + + + + + ); + } + + let curveX: number | undefined; + let curveY: number | undefined; + if (drawing.type === 'curved-arrow') { + const mx = (drawing.x1 + drawing.x2) / 2; + const my = (drawing.y1 + drawing.y2) / 2; + const len = ptDist(drawing.x1, drawing.y1, drawing.x2, drawing.y2); + const offset = len * 0.3; + const angle = angleBetween(drawing.x1, drawing.y1, drawing.x2, drawing.y2); + curveX = mx - offset * Math.sin(angle); + curveY = my + offset * Math.cos(angle); + } + + const previewAnnotation: AnnotationShape = { + id: '__preview__', + ...drawing, + cx: curveX, + cy: curveY, + strokeColor: annotationDefaults.strokeColor, + strokeWidth: annotationDefaults.strokeWidth, + fillColor: annotationDefaults.fillColor, + opacity: 0.6, + isVisible: true, + }; + return ( + {}} + onDragStart={() => {}} + onHover={() => {}} + /> + ); + }; + + // The SVG is always pointer-events:none. When a tool is active, we add a + // full-size background rect with pointer-events:auto to capture drawing. + // Individual annotation shapes always have pointer-events:auto so they + // can be selected/dragged even when no tool is active. + return ( + + {/* Background rect — captures drawing when tool active, deselects when not */} + { + if (isToolActive) { + handlePointerDown(e); + } else if (selectedId) { + setSelectedId(null); + } + }} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + /> + + {annotations.map((annotation) => ( + setSelectedId(annotation.id)} + onDragStart={(e) => handleDragStart(annotation.id, e)} + onControlDrag={ + annotation.type === 'curved-arrow' + ? (e) => handleControlDragStart(annotation.id, e) + : undefined + } + onEndpointDrag={(endpoint, e) => handleEndpointDragStart(annotation.id, endpoint, e)} + onHover={(h) => setHoveredId(h ? annotation.id : null)} + /> + ))} + {renderDrawingPreview()} + + {/* Delete button on selected annotation */} + {selectedId && !isToolActive && (() => { + const selected = annotations.find(a => a.id === selectedId); + if (!selected) return null; + const pos = getDeleteButtonPos(selected, canvasW, canvasH); + return ( + { + removeAnnotation(selectedId); + setSelectedId(null); + }} + /> + ); + })()} + + ); +} diff --git a/apps/dashboard/components/canvas/html/SnapAlignmentGuides.tsx b/apps/dashboard/components/canvas/html/SnapAlignmentGuides.tsx new file mode 100644 index 0000000..c2156b7 --- /dev/null +++ b/apps/dashboard/components/canvas/html/SnapAlignmentGuides.tsx @@ -0,0 +1,65 @@ +'use client'; + +interface SnapAlignmentGuidesProps { + canvasW: number; + canvasH: number; + offsetX: number; + offsetY: number; + isDragging: boolean; +} + +const SNAP_THRESHOLD = 6; + +export function SnapAlignmentGuides({ + canvasW, + canvasH, + offsetX, + offsetY, + isDragging, +}: SnapAlignmentGuidesProps) { + if (!isDragging) return null; + + const showVertical = Math.abs(offsetX) < SNAP_THRESHOLD; + const showHorizontal = Math.abs(offsetY) < SNAP_THRESHOLD; + + if (!showVertical && !showHorizontal) return null; + + return ( +
+ {showVertical && ( +
+ )} + {showHorizontal && ( +
+ )} +
+ ); +} diff --git a/apps/dashboard/components/canvas/html/index.ts b/apps/dashboard/components/canvas/html/index.ts new file mode 100644 index 0000000..366be9a --- /dev/null +++ b/apps/dashboard/components/canvas/html/index.ts @@ -0,0 +1,12 @@ +export { HTMLCanvasRenderer } from './HTMLCanvasRenderer'; +export { HTMLBackgroundLayer } from './HTMLBackgroundLayer'; +export { HTMLPatternLayer } from './HTMLPatternLayer'; +export { HTMLNoiseLayer } from './HTMLNoiseLayer'; +export { HTMLMainImageLayer } from './HTMLMainImageLayer'; +export type { FrameConfig } from './HTMLMainImageLayer'; +export { HTMLTextOverlayLayer } from './HTMLTextOverlayLayer'; +export { HTMLImageOverlayLayer } from './HTMLImageOverlayLayer'; +export { SVGAnnotationLayer } from './SVGAnnotationLayer'; +export { HTMLBlurRegionLayer } from './HTMLBlurRegionLayer'; +export { SnapAlignmentGuides } from './SnapAlignmentGuides'; +export { HTMLGridLayer } from './HTMLGridLayer'; diff --git a/apps/dashboard/components/canvas/overlays/ArcFrameOverlay.tsx b/apps/dashboard/components/canvas/overlays/ArcFrameOverlay.tsx new file mode 100644 index 0000000..d65f5f2 --- /dev/null +++ b/apps/dashboard/components/canvas/overlays/ArcFrameOverlay.tsx @@ -0,0 +1,218 @@ +'use client'; + +import { type ShadowConfig } from '../utils/shadow-utils'; +import { type ImageFilters } from '@/lib/store'; + +export interface FrameConfig { + enabled: boolean; + type: 'none' | 'arc-light' | 'arc-dark' | 'macos-light' | 'macos-dark' | 'windows-light' | 'windows-dark' | 'photograph' | 'glass-light' | 'glass-dark' | 'outline-light' | 'border-light' | 'border-dark'; + width: number; + color: string; + padding?: number; + title?: string; + opacity?: number; +} + +interface ArcFrameOverlayProps { + screenshot: { + offsetX: number; + offsetY: number; + rotation: number; + radius: number; + scale: number; + }; + shadow: ShadowConfig; + frame: FrameConfig; + imageScaledW: number; + imageScaledH: number; + canvasW: number; + canvasH: number; + image: HTMLImageElement; + imageOpacity: number; + imageFilters?: ImageFilters; +} + +/** + * Builds CSS box-shadow string from shadow config. + * Matches the same shadow intensity as the original Konva image shadow. + */ +function buildBoxShadow(shadow: ShadowConfig): string { + if (!shadow.enabled) return 'none'; + + const { elevation, softness, color, intensity, offsetX, offsetY } = shadow; + + // Parse shadow color + let r = 0, g = 0, b = 0; + const colorMatch = color.match(/rgba?\(([^)]+)\)/); + + if (colorMatch) { + const parts = colorMatch[1].split(',').map(s => s.trim()); + r = parseInt(parts[0]) || 0; + g = parseInt(parts[1]) || 0; + b = parseInt(parts[2]) || 0; + } else if (color.startsWith('#')) { + const hex = color.replace('#', ''); + r = parseInt(hex.slice(0, 2), 16) || 0; + g = parseInt(hex.slice(2, 4), 16) || 0; + b = parseInt(hex.slice(4, 6), 16) || 0; + } + + // Darken color for shadow (same as shadow-utils.ts) + const shadowR = Math.floor(r * 0.3); + const shadowG = Math.floor(g * 0.3); + const shadowB = Math.floor(b * 0.3); + + // Calculate offsets (same logic as getShadowProps in shadow-utils.ts) + const diag = elevation * 0.707; + let x = offsetX ?? diag; + let y = offsetY ?? diag; + + // Use same blur and intensity as Konva shadow + const effectiveBlur = Math.max(softness, 12); + const effectiveIntensity = Math.min(1, Math.max(0.4, intensity * 1.5)); + + // Create multi-layer shadow matching Konva intensity + const shadows = [ + // Primary shadow - matches Konva shadowBlur/offset + `rgba(${shadowR}, ${shadowG}, ${shadowB}, ${effectiveIntensity}) ${x}px ${y}px ${effectiveBlur}px`, + // Secondary ambient shadow for depth + `rgba(${shadowR}, ${shadowG}, ${shadowB}, ${effectiveIntensity * 0.5}) ${x * 1.5}px ${y * 1.5}px ${effectiveBlur * 2}px`, + ]; + + return shadows.join(', '); +} + +/** + * Builds CSS filter string from imageFilters + */ +function buildImageFilter(imageFilters?: ImageFilters): string | undefined { + if (!imageFilters) return undefined; + + const filters: string[] = []; + + if (imageFilters.brightness !== 100) { + filters.push(`brightness(${imageFilters.brightness / 100})`); + } + if (imageFilters.contrast !== 100) { + filters.push(`contrast(${imageFilters.contrast / 100})`); + } + if (imageFilters.saturate !== 100) { + filters.push(`saturate(${imageFilters.saturate / 100})`); + } + if (imageFilters.grayscale > 0) { + filters.push(`grayscale(${imageFilters.grayscale / 100})`); + } + if (imageFilters.sepia > 0) { + filters.push(`sepia(${imageFilters.sepia / 100})`); + } + if (imageFilters.hueRotate !== 0) { + filters.push(`hue-rotate(${imageFilters.hueRotate}deg)`); + } + if (imageFilters.blur > 0) { + filters.push(`blur(${imageFilters.blur}px)`); + } + if (imageFilters.invert > 0) { + filters.push(`invert(${imageFilters.invert / 100})`); + } + + return filters.length > 0 ? filters.join(' ') : undefined; +} + +/** + * HTML/CSS-based overlay for arc frames. + * Uses CSS box-shadow which properly respects border-radius and renders + * shadow behind the entire container (image + border). + */ +export function ArcFrameOverlay({ + screenshot, + shadow, + frame, + imageScaledW, + imageScaledH, + canvasW, + canvasH, + image, + imageOpacity, + imageFilters, +}: ArcFrameOverlayProps) { + // Only render for arc frames + if (!frame.enabled || (frame.type !== 'arc-light' && frame.type !== 'arc-dark')) { + return null; + } + + // Arc frames use configurable semi-transparent border + const borderWidth = frame.width || 8; + const defaultOpacity = frame.type === 'arc-light' ? 0.5 : 0.7; + const borderOpacity = frame.opacity ?? defaultOpacity; + const borderColor = frame.type === 'arc-light' + ? `rgba(255, 255, 255, ${borderOpacity})` + : `rgba(0, 0, 0, ${borderOpacity})`; + + const boxShadow = buildBoxShadow(shadow); + const imageFilter = buildImageFilter(imageFilters); + + // Container dimensions (image + border) + const containerW = imageScaledW; + const containerH = imageScaledH; + + // Position in center of canvas with offsets + const centerX = canvasW / 2 + screenshot.offsetX; + const centerY = canvasH / 2 + screenshot.offsetY; + + return ( +
+
+ {/* Container with border and shadow - shadow is applied to outer container */} +
+ {/* Image inside container */} + Arc framed +
+
+
+ ); +} diff --git a/apps/dashboard/components/canvas/overlays/Perspective3DOverlay.tsx b/apps/dashboard/components/canvas/overlays/Perspective3DOverlay.tsx new file mode 100644 index 0000000..5fc4802 --- /dev/null +++ b/apps/dashboard/components/canvas/overlays/Perspective3DOverlay.tsx @@ -0,0 +1,273 @@ +'use client'; + +import { Frame3DOverlay, getFrameImageStyle, type FrameConfig } from '../frames/Frame3DOverlay'; +import { type ShadowConfig } from '../utils/shadow-utils'; +import { type ImageFilters } from '@/lib/store'; + +export interface Perspective3DConfig { + perspective: number; + rotateX: number; + rotateY: number; + rotateZ: number; + translateX: number; + translateY: number; + scale: number; +} + +interface Perspective3DOverlayProps { + has3DTransform: boolean; + perspective3D: Perspective3DConfig; + screenshot: { + rotation: number; + radius: number; + }; + shadow: ShadowConfig; + frame: FrameConfig; + showFrame: boolean; + framedW: number; + framedH: number; + frameOffset: number; + windowPadding: number; + windowHeader: number; + eclipseBorder: number; + imageScaledW: number; + imageScaledH: number; + groupCenterX: number; + groupCenterY: number; + canvasW: number; + canvasH: number; + image: HTMLImageElement; + imageOpacity: number; + imageFilters?: ImageFilters; +} + +export function Perspective3DOverlay({ + has3DTransform, + perspective3D, + screenshot, + shadow, + frame, + showFrame, + framedW, + framedH, + frameOffset, + windowPadding, + windowHeader, + eclipseBorder, + imageScaledW, + imageScaledH, + groupCenterX, + groupCenterY, + canvasW, + canvasH, + image, + imageOpacity, + imageFilters, +}: Perspective3DOverlayProps) { + if (!has3DTransform) return null; + + // Build CSS filter string from imageFilters + const buildImageFilter = () => { + if (!imageFilters) return undefined; + + const filters: string[] = []; + + if (imageFilters.brightness !== 100) { + filters.push(`brightness(${imageFilters.brightness / 100})`); + } + if (imageFilters.contrast !== 100) { + filters.push(`contrast(${imageFilters.contrast / 100})`); + } + if (imageFilters.saturate !== 100) { + filters.push(`saturate(${imageFilters.saturate / 100})`); + } + if (imageFilters.grayscale > 0) { + filters.push(`grayscale(${imageFilters.grayscale / 100})`); + } + if (imageFilters.sepia > 0) { + filters.push(`sepia(${imageFilters.sepia / 100})`); + } + if (imageFilters.hueRotate !== 0) { + filters.push(`hue-rotate(${imageFilters.hueRotate}deg)`); + } + if (imageFilters.blur > 0) { + filters.push(`blur(${imageFilters.blur}px)`); + } + if (imageFilters.invert > 0) { + filters.push(`invert(${imageFilters.invert / 100})`); + } + + return filters.length > 0 ? filters.join(' ') : undefined; + }; + + const imageFilterStyle = buildImageFilter(); + + const perspective3DTransform = ` + translate(${perspective3D.translateX}%, ${perspective3D.translateY}%) + scale(${perspective3D.scale}) + rotateX(${perspective3D.rotateX}deg) + rotateY(${perspective3D.rotateY}deg) + rotateZ(${perspective3D.rotateZ + screenshot.rotation}deg) + ` + .replace(/\s+/g, ' ') + .trim(); + + // Parse shadow color and extract RGB values + const colorMatch = shadow.color.match(/rgba?\(([^)]+)\)/) + let r = 0, g = 0, b = 0; + let shadowOpacity = shadow.intensity || 0.5; + + if (colorMatch) { + const parts = colorMatch[1].split(',').map(s => s.trim()) + r = parseInt(parts[0]) || 0; + g = parseInt(parts[1]) || 0; + b = parseInt(parts[2]) || 0; + if (parts.length === 4) { + shadowOpacity = parseFloat(parts[3]) || shadow.intensity; + } + } else if (shadow.color.startsWith('#')) { + const hex = shadow.color.replace('#', '') + r = parseInt(hex.slice(0, 2), 16) || 0; + g = parseInt(hex.slice(2, 4), 16) || 0; + b = parseInt(hex.slice(4, 6), 16) || 0; + } + + // Build shadow filter using direct values from store + const buildShadowFilter = () => { + const x = shadow.enabled ? (shadow.offsetX ?? 0) : 0; + const y = shadow.enabled ? (shadow.offsetY ?? 0) : 0; + const blur = shadow.enabled ? ((shadow.softness || 15) + (shadow.spread || 0)) : 15; + const opacity = shadow.enabled ? Math.min(1, shadow.intensity) : 0.5; + + const shadows = [ + `drop-shadow(${x}px ${y}px ${blur}px rgba(${r}, ${g}, ${b}, ${opacity}))`, + `drop-shadow(0px 0px ${blur * 0.5}px rgba(${r}, ${g}, ${b}, ${opacity * 0.2}))`, + ]; + + return shadows.join(' '); + }; + + const shadowFilter = buildShadowFilter(); + + const isDark = frame.type.includes('dark'); + const isMacFrame = frame.type === 'macos-light' || frame.type === 'macos-dark'; + const isWinFrame = frame.type === 'windows-light' || frame.type === 'windows-dark'; + const isArcFrame = frame.type === 'arc-light' || frame.type === 'arc-dark'; + const isStyleFrame = ['glass-light', 'glass-dark', 'outline-light', 'border-light', 'border-dark'].includes(frame.type); + + const browserRadius = screenshot.radius; + + // Get frame container background color + const getFrameBackground = () => { + if (isMacFrame) { + return isDark ? '#3A3A3C' : '#F6F6F6'; + } + if (isWinFrame) { + return isDark ? '#292A2D' : '#FFFFFF'; + } + if (isStyleFrame) { + const styleMap: Record = { + 'glass-light': `rgba(255, 255, 255, ${frame.opacity ?? 0.25})`, + 'glass-dark': `rgba(0, 0, 0, ${frame.opacity ?? 0.3})`, + 'outline-light': `rgba(255, 255, 255, ${frame.opacity ?? 0.35})`, + 'border-light': 'rgb(255, 255, 255)', + 'border-dark': 'rgb(26, 26, 26)', + }; + return styleMap[frame.type] || 'transparent'; + } + return 'transparent'; + }; + + // Calculate image border radius + const getImageBorderRadius = () => { + if (isMacFrame || isWinFrame) { + const innerRadius = Math.max(0, browserRadius - windowPadding); + return `0 0 ${innerRadius}px ${innerRadius}px`; + } + return `${screenshot.radius}px`; + }; + + return ( +
+
+ {/* Frame background container for macOS/Windows */} +
0 ? screenshot.radius + windowPadding : 0) : browserRadius}px` : undefined, + overflow: 'hidden', + }} + > + + + 3D transformed +
+
+
+ ); +} + diff --git a/apps/dashboard/components/canvas/utils/canvas-dimensions.ts b/apps/dashboard/components/canvas/utils/canvas-dimensions.ts new file mode 100644 index 0000000..d353a7b --- /dev/null +++ b/apps/dashboard/components/canvas/utils/canvas-dimensions.ts @@ -0,0 +1,162 @@ +export interface CanvasDimensions { + canvasW: number; + canvasH: number; + contentW: number; + contentH: number; + imageScaledW: number; + imageScaledH: number; + framedW: number; + framedH: number; + frameOffset: number; + windowPadding: number; + windowHeader: number; + eclipseBorder: number; + groupCenterX: number; + groupCenterY: number; + imageX: number; + imageY: number; +} + +export function calculateCanvasDimensions( + image: HTMLImageElement, + containerWidth: number, + containerHeight: number, + viewportSize: { width: number; height: number }, + canvas: { padding: number }, + screenshot: { + scale: number; + offsetX: number; + offsetY: number; + radius: number; + }, + frame: { + enabled: boolean; + type: string; + width: number; + padding?: number; + }, + browserHeaderSize?: number +): CanvasDimensions { + const imageAspect = image.naturalWidth / image.naturalHeight; + const canvasAspect = containerWidth / containerHeight; + const isMobileViewport = viewportSize.width < 768; + + const availableWidth = Math.min(viewportSize.width * 1.1, containerWidth); + const availableHeight = Math.min(viewportSize.height * 1.1, containerHeight); + + let canvasW: number, canvasH: number; + if (availableWidth / availableHeight > canvasAspect) { + canvasH = availableHeight - canvas.padding * 2; + canvasW = canvasH * canvasAspect; + } else { + canvasW = availableWidth - canvas.padding * 2; + canvasH = canvasW / canvasAspect; + } + + // Maintain a minimum preview size on larger screens but keep true ratio on mobile. + const minContentSize = isMobileViewport ? 0 : 300; + if (minContentSize > 0) { + const minDimension = Math.min(canvasW, canvasH); + if (minDimension < minContentSize && minDimension > 0) { + const scaleFactor = minContentSize / minDimension; + canvasW *= scaleFactor; + canvasH *= scaleFactor; + } + } + + // Adapt padding so small canvases don't end up with huge borders. + const maxPaddingRatio = isMobileViewport ? 0.05 : 0.08; + const paddingLimit = Math.min( + canvas.padding, + Math.min(canvasW, canvasH) * maxPaddingRatio + ); + const appliedPadding = Math.max(0, paddingLimit); + + const contentW = Math.max(0, canvasW - appliedPadding * 2); + const contentH = Math.max(0, canvasH - appliedPadding * 2); + + let imageScaledW: number, imageScaledH: number; + if (contentW / contentH > imageAspect) { + imageScaledH = contentH * screenshot.scale; + imageScaledW = imageScaledH * imageAspect; + } else { + imageScaledW = contentW * screenshot.scale; + imageScaledH = imageScaledW / imageAspect; + } + + const showFrame = frame.enabled && frame.type !== 'none'; + + if (showFrame) { + imageScaledW *= 0.88; + imageScaledH *= 0.88; + } + + const isWindowFrame = ['macos-light', 'macos-dark', 'windows-light', 'windows-dark'].includes(frame.type); + const isMacosFrame = frame.type === 'macos-light' || frame.type === 'macos-dark'; + const isWindowsFrame = frame.type === 'windows-light' || frame.type === 'windows-dark'; + const isPhotograph = frame.type === 'photograph'; + const isStyleFrame = ['glass-light', 'glass-dark', 'outline-light', 'border-light', 'border-dark'].includes(frame.type); + + // No frameOffset - borders are applied directly to image elements + const frameOffset = 0; + + // Calculate style frame padding from store value (percentage of image width) + let stylePadding = 0; + if (showFrame && isStyleFrame) { + const paddingPct = (frame.padding ?? 2) / 100; + stylePadding = Math.round(imageScaledW * paddingPct); + } + + // Polaroid needs padding for the white border (8px sides/top) + // Style frames use their own calculated padding + const windowPadding = showFrame && isPhotograph ? 8 : (showFrame && isStyleFrame ? stylePadding : 0); + + // Header/footer height: + // - Safari (macOS): 22px toolbar + // - Chrome (Windows): 36px (tab bar + address bar) + // - Polaroid: 16px extra bottom (24px total bottom - 8px already in windowPadding) + const defaultHeader = isMacosFrame ? 22 : (isWindowsFrame ? 36 : 0); + const browserHeader = (isMacosFrame || isWindowsFrame) && browserHeaderSize != null ? Math.round(defaultHeader * (browserHeaderSize / 100)) : defaultHeader; + const windowHeader = showFrame && isMacosFrame ? browserHeader : (showFrame && isWindowsFrame ? browserHeader : (showFrame && isPhotograph ? 16 : 0)); + + const eclipseBorder = 0; + + const framedW = + imageScaledW + frameOffset * 2 + windowPadding * 2 + eclipseBorder; + const framedH = + imageScaledH + + frameOffset * 2 + + windowPadding * 2 + + windowHeader + + eclipseBorder; + + const groupCenterX = canvasW / 2 + screenshot.offsetX; + const groupCenterY = canvasH / 2 + screenshot.offsetY; + const imageX = groupCenterX + frameOffset + windowPadding - imageScaledW / 2; + const imageY = + groupCenterY + + frameOffset + + windowPadding + + windowHeader - + imageScaledH / 2; + + return { + canvasW, + canvasH, + contentW, + contentH, + imageScaledW, + imageScaledH, + framedW, + framedH, + frameOffset, + windowPadding, + windowHeader, + eclipseBorder, + groupCenterX, + groupCenterY, + imageX, + imageY, + }; +} + diff --git a/apps/dashboard/components/canvas/utils/gradient-utils.ts b/apps/dashboard/components/canvas/utils/gradient-utils.ts new file mode 100644 index 0000000..fa4e18b --- /dev/null +++ b/apps/dashboard/components/canvas/utils/gradient-utils.ts @@ -0,0 +1,90 @@ +export function parseLinearGradient( + gradientString: string, + width: number, + height: number +) { + const match = gradientString.match(/linear-gradient\((.+)\)/); + if (!match) return null; + + const content = match[1]; + + let startPoint = { x: 0, y: 0 }; + let endPoint = { x: width, y: 0 }; + let angle = 0; + + const degMatch = content.match(/(\d+)deg/); + if (degMatch) { + angle = parseInt(degMatch[1], 10); + const rad = (angle * Math.PI) / 180; + const length = Math.sqrt(width * width + height * height); + const centerX = width / 2; + const centerY = height / 2; + + startPoint = { + x: centerX - (length / 2) * Math.cos(rad), + y: centerY - (length / 2) * Math.sin(rad), + }; + endPoint = { + x: centerX + (length / 2) * Math.cos(rad), + y: centerY + (length / 2) * Math.sin(rad), + }; + } else if (content.includes('to right')) { + startPoint = { x: 0, y: 0 }; + endPoint = { x: width, y: 0 }; + } else if (content.includes('to left')) { + startPoint = { x: width, y: 0 }; + endPoint = { x: 0, y: 0 }; + } else if (content.includes('to bottom')) { + startPoint = { x: 0, y: 0 }; + endPoint = { x: 0, y: height }; + } else if (content.includes('to top')) { + startPoint = { x: 0, y: height }; + endPoint = { x: 0, y: 0 }; + } + + const colorStops: (number | string)[] = []; + + const colorStopRegex = /(rgb\([^)]+\)|rgba\([^)]+\)|#[0-9A-Fa-f]{3,8})(?:\s+(\d+(?:\.\d+)?%))?/g; + let colorMatch; + const colorMatches: Array<{ color: string; percentage?: string }> = []; + + while ((colorMatch = colorStopRegex.exec(content)) !== null) { + colorMatches.push({ + color: colorMatch[1], + percentage: colorMatch[2], + }); + } + + if (colorMatches.length > 0) { + colorMatches.forEach((match) => { + if (match.percentage) { + const position = parseFloat(match.percentage) / 100; + colorStops.push(position, match.color); + } else { + const index = colorMatches.indexOf(match); + const position = colorMatches.length > 1 ? index / (colorMatches.length - 1) : 0; + colorStops.push(position, match.color); + } + }); + } else { + const parts = content.split(',').map((p) => p.trim()); + const colors = parts.filter((part) => { + const trimmed = part.trim(); + return trimmed.includes('rgb') || trimmed.includes('#') || trimmed.includes('rgba'); + }); + + colors.forEach((color, index) => { + const position = colors.length > 1 ? index / (colors.length - 1) : 0; + colorStops.push(position, color.trim()); + }); + } + + if (colorStops.length === 0) return null; + + return { + startPoint, + endPoint, + colorStops, + }; +} + diff --git a/apps/dashboard/components/canvas/utils/shadow-utils.ts b/apps/dashboard/components/canvas/utils/shadow-utils.ts new file mode 100644 index 0000000..c684558 --- /dev/null +++ b/apps/dashboard/components/canvas/utils/shadow-utils.ts @@ -0,0 +1,107 @@ +export interface ShadowProps { + shadowColor?: string; + shadowBlur?: number; + shadowOffsetX?: number; + shadowOffsetY?: number; + shadowOpacity?: number; +} + +export interface ShadowConfig { + enabled: boolean; + elevation: number; + side: 'bottom' | 'right' | 'bottom-right'; + softness: number; + spread: number; + color: string; + intensity: number; + offsetX?: number; + offsetY?: number; +} + +/** + * Calculate minimum padding around the image for the blur-div shadow. + * Blur-divs fade with Gaussian falloff so we only need room for the + * shadow offset plus a fraction of the blur (the outer fringe is + * already nearly invisible and clips gracefully). + */ +export function calculateShadowPadding(shadow: ShadowConfig): number { + if (!shadow.enabled) return 0; + + const { elevation, softness, offsetX, offsetY } = shadow; + + const diag = elevation * 0.707; + const x = offsetX ?? diag; + const y = offsetY ?? diag; + const effectiveBlur = Math.max(softness, 12); + + // Need room for offset + ~35 % of the blur spread + const maxOffset = Math.max(Math.abs(x), Math.abs(y)); + return Math.ceil(maxOffset * 1.5 + effectiveBlur * 0.35); +} + +export function getShadowProps(shadow: ShadowConfig): ShadowProps | Record { + if (!shadow.enabled) return {}; + + const { elevation, side, softness, color, intensity, offsetX, offsetY } = shadow; + + let x = 0; + let y = 0; + + if (offsetX !== undefined && offsetY !== undefined) { + x = offsetX; + y = offsetY; + } else { + // Default to bottom-right shadow for natural lighting effect + const diag = elevation * 0.707; + const offset = + side === 'bottom' + ? { x: elevation * 0.3, y: elevation } // Slight right offset for natural look + : side === 'right' + ? { x: elevation, y: elevation * 0.3 } // Slight bottom offset + : side === 'bottom-right' + ? { x: diag, y: diag } + : { x: elevation * 0.5, y: elevation * 0.8 }; // Default: more bottom, some right + x = offset.x; + y = offset.y; + } + + // Parse color and darken it for better shadow visibility + const colorMatch = color.match(/rgba?\(([^)]+)\)/) + let shadowColor = 'rgba(0, 0, 0, 1)' // Default to black for best visibility + + if (colorMatch) { + const parts = colorMatch[1].split(',').map(s => s.trim()) + const r = parseInt(parts[0]) || 0; + const g = parseInt(parts[1]) || 0; + const b = parseInt(parts[2]) || 0; + // Darken the color for shadow (multiply by 0.3 to make it darker) + const darkR = Math.floor(r * 0.3); + const darkG = Math.floor(g * 0.3); + const darkB = Math.floor(b * 0.3); + shadowColor = `rgba(${darkR}, ${darkG}, ${darkB}, 1)` + } else if (color.startsWith('#')) { + const hex = color.replace('#', '') + const r = parseInt(hex.slice(0, 2), 16) || 0; + const g = parseInt(hex.slice(2, 4), 16) || 0; + const b = parseInt(hex.slice(4, 6), 16) || 0; + // Darken the color + const darkR = Math.floor(r * 0.3); + const darkG = Math.floor(g * 0.3); + const darkB = Math.floor(b * 0.3); + shadowColor = `rgba(${darkR}, ${darkG}, ${darkB}, 1)` + } + + // Ensure minimum blur for soft shadows + const effectiveBlur = Math.max(softness, 12); + // Use high intensity for visible shadows + const effectiveIntensity = Math.min(1, Math.max(0.4, intensity * 1.5)); + + return { + shadowColor, + shadowBlur: effectiveBlur, + shadowOffsetX: x, + shadowOffsetY: y, + shadowOpacity: effectiveIntensity, + }; +} + diff --git a/apps/dashboard/components/controls/CleanUploadState.tsx b/apps/dashboard/components/controls/CleanUploadState.tsx new file mode 100644 index 0000000..18a752e --- /dev/null +++ b/apps/dashboard/components/controls/CleanUploadState.tsx @@ -0,0 +1,534 @@ +"use client"; + +import { Button } from "@castfy/ui/components/button"; +import { Input } from "@castfy/ui/components/input"; +import { cn } from "@castfy/ui/lib/utils"; +import { + Camera01Icon, + CommandIcon, + Globe02Icon, + Loading03Icon, +} from "hugeicons-react"; +import { Moon, Sun } from "lucide-react"; +import React from "react"; +import { useDropzone } from "react-dropzone"; +import { SegmentedControl } from "@/components/ui/segmented-control"; +import { ALLOWED_IMAGE_TYPES, MAX_IMAGE_SIZE } from "@/lib/constants"; +import { getBackgroundCSS } from "@/lib/constants/backgrounds"; +import { useEditorStore, useImageStore } from "@/lib/store"; + +const TRANSITION_DURATION = 400; // ms +type ColorScheme = "light" | "dark"; + +function extractImageUrl(style: React.CSSProperties): string | null { + const bg = style.backgroundImage; + if (!bg || typeof bg !== "string") { + return null; + } + const match = bg.match(/url\(([^)]+)\)/); + if (!match) { + return null; + } + return match[1].replace(/['"]/g, ""); +} + +function preloadImage(url: string): Promise { + return new Promise((resolve, reject) => { + const img = new window.Image(); + img.onload = () => resolve(url); + img.onerror = () => reject(new Error(`Failed to load: ${url}`)); + img.src = url; + }); +} + +export function CleanUploadState() { + const [isDragActive, setIsDragActive] = React.useState(false); + const [error, setError] = React.useState(null); + const [screenshotUrl, setScreenshotUrl] = React.useState(""); + const [colorScheme, setColorScheme] = React.useState("light"); + const [isCapturing, setIsCapturing] = React.useState(false); + + const { setScreenshot } = useEditorStore(); + const { addImages, setImage, backgroundConfig } = useImageStore(); + const containerRef = React.useRef(null); + + // Crossfade state + const backgroundStyle = React.useMemo( + () => getBackgroundCSS(backgroundConfig), + [backgroundConfig] + ); + const [activeLayer, setActiveLayer] = React.useState<"a" | "b">("a"); + const [layerAStyle, setLayerAStyle] = + React.useState(backgroundStyle); + const [layerBStyle, setLayerBStyle] = + React.useState(backgroundStyle); + const [showTransition, setShowTransition] = React.useState(false); + const prevConfigRef = React.useRef(backgroundConfig); + const isFirstRender = React.useRef(true); + const timeoutRef = React.useRef>(undefined); + + React.useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + setLayerAStyle(backgroundStyle); + setLayerBStyle(backgroundStyle); + return; + } + + const prev = prevConfigRef.current; + const changed = + prev.type !== backgroundConfig.type || + prev.value !== backgroundConfig.value; + + if (!changed) { + if (activeLayer === "a") { + setLayerAStyle(backgroundStyle); + } else { + setLayerBStyle(backgroundStyle); + } + return; + } + + prevConfigRef.current = backgroundConfig; + let cancelled = false; + + const applyNewBackground = (style: React.CSSProperties) => { + if (cancelled) { + return; + } + if (activeLayer === "a") { + setLayerBStyle(style); + setShowTransition(true); + requestAnimationFrame(() => { + if (!cancelled) { + setActiveLayer("b"); + } + }); + } else { + setLayerAStyle(style); + setShowTransition(true); + requestAnimationFrame(() => { + if (!cancelled) { + setActiveLayer("a"); + } + }); + } + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout( + () => setShowTransition(false), + TRANSITION_DURATION + 50 + ); + }; + + if (backgroundConfig.type === "image") { + const url = extractImageUrl(backgroundStyle); + if (url) { + preloadImage(url) + .then((loadedUrl) => { + applyNewBackground({ + ...backgroundStyle, + backgroundImage: `url(${loadedUrl})`, + }); + }) + .catch(() => applyNewBackground(backgroundStyle)); + return () => { + cancelled = true; + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + } + } + + applyNewBackground(backgroundStyle); + return () => { + cancelled = true; + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, [backgroundConfig, backgroundStyle, activeLayer]); + + const validateFile = React.useCallback((file: File): string | null => { + if (!ALLOWED_IMAGE_TYPES.includes(file.type)) { + return "File type not supported. Please use: PNG, JPG, WEBP"; + } + if (file.size > MAX_IMAGE_SIZE) { + return `File size too large. Maximum size is ${MAX_IMAGE_SIZE / 1024 / 1024}MB`; + } + return null; + }, []); + + const handleFile = React.useCallback( + (file: File) => { + const validationError = validateFile(file); + if (validationError) { + setError(validationError); + return; + } + setError(null); + const imageUrl = URL.createObjectURL(file); + setScreenshot({ src: imageUrl }); + }, + [validateFile, setScreenshot] + ); + + const onDrop = React.useCallback( + (acceptedFiles: File[]) => { + if (!acceptedFiles.length) { + return; + } + addImages(acceptedFiles); + handleFile(acceptedFiles[0]); + }, + [addImages, handleFile] + ); + + const { + getRootProps, + getInputProps, + isDragActive: dropzoneActive, + open, + } = useDropzone({ + onDrop, + accept: { + "image/jpeg": [".jpg", ".jpeg"], + "image/png": [".png"], + "image/webp": [".webp"], + }, + maxSize: MAX_IMAGE_SIZE, + multiple: true, + noClick: true, + onDragEnter: () => { + setIsDragActive(true); + setError(null); + }, + onDragLeave: () => setIsDragActive(false), + onDropRejected: (rejectedFiles) => { + if (rejectedFiles.length > 0) { + const rejection = rejectedFiles[0]; + if (rejection.errors.some((e) => e.code === "file-too-large")) { + setError( + `File size too large. Maximum size is ${MAX_IMAGE_SIZE / 1024 / 1024}MB` + ); + } else if ( + rejection.errors.some((e) => e.code === "file-invalid-type") + ) { + setError("File type not supported. Please use: PNG, JPG, WEBP"); + } else { + setError("Failed to upload file. Please try again."); + } + } + }, + }); + + // Auto-focus the container so paste events work immediately + React.useEffect(() => { + if (containerRef.current) { + containerRef.current.focus(); + } + }, []); + + const handlePaste = React.useCallback( + (e: React.ClipboardEvent | ClipboardEvent) => { + const clipboardData = "clipboardData" in e ? e.clipboardData : null; + const items = clipboardData?.items; + if (!items) { + return; + } + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.type.startsWith("image/")) { + e.preventDefault(); + const file = item.getAsFile(); + if (file) { + addImages([file]); + handleFile(file); + } + break; + } + } + }, + [addImages, handleFile] + ); + + // Listen on both document and the container for paste events + React.useEffect(() => { + const handler = (e: ClipboardEvent) => handlePaste(e); + document.addEventListener("paste", handler); + return () => document.removeEventListener("paste", handler); + }, [handlePaste]); + + const handleCaptureScreenshot = async () => { + if (!screenshotUrl.trim()) { + setError("Please enter a URL"); + return; + } + let finalUrl = screenshotUrl.trim(); + if (!finalUrl.match(/^https?:\/\//i)) { + finalUrl = `https://${finalUrl}`; + } + setIsCapturing(true); + setError(null); + try { + const response = await fetch("/api/screenshot", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: finalUrl, + deviceType: "desktop", + colorScheme, + }), + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || "Failed to capture screenshot"); + } + let base64Data = data.screenshot.trim(); + if (base64Data.includes(",")) { + base64Data = base64Data.split(",")[1]; + } + base64Data = base64Data.replace(/\s/g, ""); + const byteCharacters = atob(base64Data); + const byteNumbers = new Array(byteCharacters.length); + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + const blob = new Blob([byteArray], { type: "image/png" }); + const blobUrl = URL.createObjectURL(blob); + const file = new File([blob], `screenshot-${colorScheme}.png`, { + type: "image/png", + }); + setScreenshot({ src: blobUrl }); + setImage(file); + setScreenshotUrl(""); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to capture screenshot" + ); + } finally { + setIsCapturing(false); + } + }; + + const active = isDragActive || dropzoneActive; + + return ( +
+ {/* Background Layer A */} +
+ {/* Background Layer B */} +
+ + + {/* Upload area with plus icon */} +
+ {/* Plus icon */} + + + + + + {/* Placeholder text */} +

+ {active + ? "Drop the image here..." + : "Drag & drop, click to browse, or paste"} +

+ + {!active && ( +
+ + + V + + + to paste +
+ )} + + {/* Screenshot URL input */} + {!active && ( +
e.stopPropagation()} + > +
+
+ + or + +
+
+
+
+ + setScreenshotUrl(e.target.value)} + onKeyDown={(e) => + e.key === "Enter" && handleCaptureScreenshot() + } + placeholder="Enter website URL..." + style={{ color: "rgba(255,255,255,0.9)" }} + type="url" + value={screenshotUrl} + /> +
+ setColorScheme(value as ColorScheme)} + options={[ + { + id: "light", + icon: , + ariaLabel: "Light", + }, + { + id: "dark", + icon: , + ariaLabel: "Dark", + }, + ]} + size="sm" + value={colorScheme} + /> +
+ +
+
+ )} + + {error && ( +
+ {error} +
+ )} +
+
+ ); +} diff --git a/apps/dashboard/components/mockups/HTMLMockupRenderer.tsx b/apps/dashboard/components/mockups/HTMLMockupRenderer.tsx new file mode 100644 index 0000000..5d4e128 --- /dev/null +++ b/apps/dashboard/components/mockups/HTMLMockupRenderer.tsx @@ -0,0 +1,139 @@ +'use client'; + +import { useRef, useState, useCallback, useEffect } from 'react'; +import { useImageStore } from '@/lib/store'; +import { getMockupDefinition } from '@/lib/constants/mockups'; +import type { Mockup } from '@/types/mockup'; + +interface HTMLMockupRendererProps { + mockup: Mockup; + canvasWidth: number; + canvasHeight: number; +} + +export function HTMLMockupRenderer({ mockup, canvasWidth, canvasHeight }: HTMLMockupRendererProps) { + const { uploadedImageUrl, updateMockup } = useImageStore(); + const definition = getMockupDefinition(mockup.definitionId); + const containerRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); + const [mockupSize, setMockupSize] = useState({ width: 0, height: 0 }); + const [mockupLoaded, setMockupLoaded] = useState(false); + + if (!definition || !mockup.isVisible) return null; + + // Calculate mockup dimensions + const handleMockupLoad = useCallback((e: React.SyntheticEvent) => { + const img = e.currentTarget; + const mockupAspectRatio = img.naturalWidth / img.naturalHeight; + const mockupWidth = mockup.size; + const mockupHeight = mockupWidth / mockupAspectRatio; + setMockupSize({ width: mockupWidth, height: mockupHeight }); + setMockupLoaded(true); + }, [mockup.size]); + + // Calculate screen area dimensions + const screenAreaX = definition.screenArea.x * mockupSize.width; + const screenAreaY = definition.screenArea.y * mockupSize.height; + const screenAreaWidth = definition.screenArea.width * mockupSize.width; + const screenAreaHeight = definition.screenArea.height * mockupSize.height; + const borderRadius = (definition.screenArea.borderRadius || 0) * mockupSize.width; + + // Handle drag + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + setDragStart({ + x: e.clientX - mockup.position.x, + y: e.clientY - mockup.position.y, + }); + }, [mockup.position.x, mockup.position.y]); + + useEffect(() => { + if (!isDragging) return; + + const handleMouseMove = (e: MouseEvent) => { + const newX = e.clientX - dragStart.x; + const newY = e.clientY - dragStart.y; + updateMockup(mockup.id, { position: { x: newX, y: newY } }); + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, [isDragging, dragStart, mockup.id, updateMockup]); + + return ( +
+ {/* Mockup frame image */} + {definition.name} + + {/* User image clipped to screen area */} + {uploadedImageUrl && mockupLoaded && ( +
+ User content +
+ )} +
+ ); +} diff --git a/apps/dashboard/components/mockups/MockupControls.tsx b/apps/dashboard/components/mockups/MockupControls.tsx new file mode 100644 index 0000000..0418c98 --- /dev/null +++ b/apps/dashboard/components/mockups/MockupControls.tsx @@ -0,0 +1,234 @@ +'use client' + +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Slider } from '@/components/ui/slider' +import { useImageStore } from '@/lib/store' +import { Delete02Icon, ViewIcon, ViewOffSlashIcon } from 'hugeicons-react' +import { getMockupDefinition } from '@/lib/constants/mockups' +import Image from 'next/image' + +export function MockupControls() { + const { + mockups, + updateMockup, + removeMockup, + clearMockups, + } = useImageStore() + + const [selectedMockupId, setSelectedMockupId] = useState(null) + + const selectedMockup = mockups.find( + (mockup) => mockup.id === selectedMockupId + ) + + const selectedDefinition = selectedMockup + ? getMockupDefinition(selectedMockup.definitionId) + : null + + const handleUpdateSize = (value: number[]) => { + if (selectedMockup) { + updateMockup(selectedMockup.id, { size: value[0] }) + } + } + + const handleUpdateRotation = (value: number[]) => { + if (selectedMockup) { + updateMockup(selectedMockup.id, { rotation: value[0] }) + } + } + + const handleUpdateOpacity = (value: number[]) => { + if (selectedMockup) { + updateMockup(selectedMockup.id, { opacity: value[0] }) + } + } + + const handleToggleVisibility = (id: string) => { + const mockup = mockups.find((m) => m.id === id) + if (mockup) { + updateMockup(id, { isVisible: !mockup.isVisible }) + } + } + + const handleUpdatePosition = (axis: 'x' | 'y', value: number[]) => { + if (selectedMockup) { + updateMockup(selectedMockup.id, { + position: { + ...selectedMockup.position, + [axis]: value[0], + }, + }) + } + } + + return ( +
+
+

Mockups

+ +
+ + {mockups.length > 0 && ( +
+

Manage Mockups

+
+ {mockups.map((mockup) => { + const definition = getMockupDefinition(mockup.definitionId) + return ( +
setSelectedMockupId(mockup.id)} + > + +
+ {definition && ( + {definition.name} + )} +
+ + {definition?.name || 'Mockup'} + + +
+ ) + })} +
+
+ )} + + {selectedMockup && selectedDefinition && ( +
+
+

+ Edit Mockup +

+ +
+ +
+ +
+ +
+ +
+ +
+ +
+

Position

+
+ handleUpdatePosition('x', value)} + max={1600} + min={0} + step={1} + label="X Position" + valueDisplay={`${Math.round(selectedMockup.position.x)}px`} + /> +
+ +
+ handleUpdatePosition('y', value)} + max={1000} + min={0} + step={1} + label="Y Position" + valueDisplay={`${Math.round(selectedMockup.position.y)}px`} + /> +
+
+ + +
+
+ )} +
+ ) +} + diff --git a/apps/dashboard/components/mockups/MockupGallery.tsx b/apps/dashboard/components/mockups/MockupGallery.tsx new file mode 100644 index 0000000..c55708a --- /dev/null +++ b/apps/dashboard/components/mockups/MockupGallery.tsx @@ -0,0 +1,205 @@ +'use client' + +import { useState } from 'react' +import { useImageStore } from '@/lib/store' +import { MOCKUP_DEFINITIONS, getMockupsByType } from '@/lib/constants/mockups' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { useResponsiveCanvasDimensions } from '@/hooks/useAspectRatioDimensions' +import Image from 'next/image' +import { SmartPhone01Icon, LaptopIcon, ComputerIcon, Watch02Icon } from 'hugeicons-react' + +export function MockupGallery() { + const { addMockup } = useImageStore() + const [activeType, setActiveType] = useState<'iphone' | 'macbook' | 'imac' | 'iwatch'>('macbook') + const responsiveDimensions = useResponsiveCanvasDimensions() + + const getDefaultPosition = (mockupSize: number, mockupType: string) => { + const canvasWidth = responsiveDimensions.width || 1920 + const canvasHeight = responsiveDimensions.height || 1080 + + let aspectRatio = 16 / 9 + if (mockupType === 'iphone') aspectRatio = 9 / 16 + else if (mockupType === 'iwatch') aspectRatio = 1 + else if (mockupType === 'imac') aspectRatio = 2146 / 1207 + + const mockupHeight = mockupSize / aspectRatio + + return { + x: Math.max(20, (canvasWidth / 2) - (mockupSize / 2)), + y: Math.max(20, (canvasHeight / 2) - (mockupHeight / 2)), + } + } + + const handleAddMockup = (definitionId: string) => { + const definition = MOCKUP_DEFINITIONS.find(d => d.id === definitionId) + let defaultSize = 600 + if (definition?.type === 'iphone') defaultSize = 220 + else if (definition?.type === 'iwatch') defaultSize = 150 + else if (definition?.type === 'imac') defaultSize = 500 + + const defaultPosition = getDefaultPosition(defaultSize, definition?.type || 'macbook') + + addMockup({ + definitionId, + position: defaultPosition, + size: defaultSize, + rotation: 0, + opacity: 1, + isVisible: true, + imageFit: 'cover', + }) + } + + const macbookMockups = getMockupsByType('macbook') + const iphoneMockups = getMockupsByType('iphone') + const imacMockups = getMockupsByType('imac') + const iwatchMockups = getMockupsByType('iwatch') + + return ( +
+
+

Device Mockups

+

+ Add device frames to showcase your designs +

+
+ + setActiveType(v as 'iphone' | 'macbook' | 'imac' | 'iwatch')}> + + + + MacBook + + + + iMac + + + + Watch + + + + iPhone + + + + +
+ {macbookMockups.map((mockup) => ( + + ))} +
+
+ + +
+ {imacMockups.map((mockup) => ( + + ))} +
+
+ + +
+ {iwatchMockups.map((mockup) => ( + + ))} +
+
+ + + {iphoneMockups.length > 0 ? ( +
+ {iphoneMockups.map((mockup) => ( + + ))} +
+ ) : ( +
+ +

iPhone mockups coming soon

+
+ )} +
+
+
+ ) +} + diff --git a/apps/dashboard/components/mockups/MockupRenderer.tsx b/apps/dashboard/components/mockups/MockupRenderer.tsx new file mode 100644 index 0000000..7666f61 --- /dev/null +++ b/apps/dashboard/components/mockups/MockupRenderer.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { HTMLMockupRenderer } from './HTMLMockupRenderer'; +import type { Mockup } from '@/types/mockup'; + +interface MockupRendererProps { + mockup: Mockup; + canvasWidth: number; + canvasHeight: number; +} + +/** + * Unified mockup renderer using HTML/CSS. + * Supports all mockup types: iPhone, MacBook, iMac, iWatch. + */ +export function MockupRenderer({ mockup, canvasWidth, canvasHeight }: MockupRendererProps) { + return ( + + ); +} diff --git a/apps/dashboard/components/mockups/index.ts b/apps/dashboard/components/mockups/index.ts new file mode 100644 index 0000000..f6c46e9 --- /dev/null +++ b/apps/dashboard/components/mockups/index.ts @@ -0,0 +1,4 @@ +export { MockupGallery } from './MockupGallery' +export { MockupControls } from './MockupControls' +export { MockupRenderer } from './MockupRenderer' +export { HTMLMockupRenderer } from './HTMLMockupRenderer' diff --git a/apps/dashboard/components/ui/cached-image.tsx b/apps/dashboard/components/ui/cached-image.tsx new file mode 100644 index 0000000..9e77188 --- /dev/null +++ b/apps/dashboard/components/ui/cached-image.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { cn } from "@castfy/ui/lib/utils"; +import Image from "next/image"; +import { useState } from "react"; + +interface CachedImageProps { + alt: string; + className?: string; + loading?: "lazy" | "eager"; + onError?: () => void; + onLoad?: () => void; + src: string; +} + +/** + * CachedImage component - uses Next.js Image for optimized loading and caching. + * Handles external images with proper error states. + */ +export function CachedImage({ + src, + alt, + className, + loading = "lazy", + onLoad, + onError, +}: CachedImageProps) { + const [hasError, setHasError] = useState(false); + + if (hasError) { + return ( +
+ Failed +
+ ); + } + + return ( + {alt} { + setHasError(true); + onError?.(); + }} + onLoad={onLoad} + sizes="(max-width: 768px) 20vw, 10vw" + src={src} + /> + ); +} diff --git a/apps/dashboard/components/ui/color-picker.tsx b/apps/dashboard/components/ui/color-picker.tsx new file mode 100644 index 0000000..adf976b --- /dev/null +++ b/apps/dashboard/components/ui/color-picker.tsx @@ -0,0 +1,536 @@ +"use client"; + +import { Input } from "@castfy/ui/components/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@castfy/ui/components/popover"; +import { cn } from "@castfy/ui/lib/utils"; +import React from "react"; +import { z } from "zod"; + +interface ColorPickerProps { + className?: string; + color: string; + onChange: (color: string) => void; +} + +interface HsvaColor { + alpha: number; + h: number; + s: number; + v: number; +} + +const defaultColor = { r: 125, g: 212, b: 173, alpha: 1 }; + +// Convert HSV to RGB +function hsvToRgb(h: number, s: number, v: number): [number, number, number] { + const c = v * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = v - c; + + let r = 0, + g = 0, + b = 0; + + if (h >= 0 && h < 60) { + r = c; + g = x; + b = 0; + } else if (h >= 60 && h < 120) { + r = x; + g = c; + b = 0; + } else if (h >= 120 && h < 180) { + r = 0; + g = c; + b = x; + } else if (h >= 180 && h < 240) { + r = 0; + g = x; + b = c; + } else if (h >= 240 && h < 300) { + r = x; + g = 0; + b = c; + } else { + r = c; + g = 0; + b = x; + } + + return [ + Math.round((r + m) * 255), + Math.round((g + m) * 255), + Math.round((b + m) * 255), + ]; +} + +// Convert RGB to HSV +function rgbToHsv(r: number, g: number, b: number): [number, number, number] { + r /= 255; + g /= 255; + b /= 255; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const d = max - min; + + let h = 0; + const s = max === 0 ? 0 : d / max; + const v = max; + + if (max !== min) { + switch (max) { + case r: + h = ((g - b) / d + (g < b ? 6 : 0)) * 60; + break; + case g: + h = ((b - r) / d + 2) * 60; + break; + case b: + h = ((r - g) / d + 4) * 60; + break; + default: + break; + } + } + + return [h, s, v]; +} + +// Convert hex to RGB +function hexToRgb(hex: string): [number, number, number] | null { + // biome-ignore lint/performance/useTopLevelRegex: x.toString(16).padStart(2, "0")).join("")}`; +} + +// Parse the comma-separated rgba(...) format emitted by this picker. +function rgbaToColor( + color: string +): { r: number; g: number; b: number; alpha: number } | null { + // Capture integer red, green, blue channels and a decimal alpha channel. + const result = + // biome-ignore lint/performance/useTopLevelRegex: 255 || g > 255 || b > 255 || alpha > 1) { + return null; + } + + return { r, g, b, alpha }; +} + +function colorToHsva(color: string): HsvaColor { + const rgb = hexToRgb(color); + const parsed = rgb + ? { r: rgb[0], g: rgb[1], b: rgb[2], alpha: 1 } + : rgbaToColor(color) || defaultColor; + const [h, s, v] = rgbToHsv(parsed.r, parsed.g, parsed.b); + + return { h, s, v, alpha: parsed.alpha }; +} + +const hexColorSchema = z + .string() + .trim() + .regex(/^#?[0-9a-fA-F]{6}$/, "Invalid hex color") + .transform((value) => (value.startsWith("#") ? value : `#${value}`)); + +export function ColorPicker({ color, onChange, className }: ColorPickerProps) { + const [isOpen, setIsOpen] = React.useState(false); + const { h, s, v, alpha } = colorToHsva(color); + const currentRgb = hsvToRgb(h, s, v); + const currentHex = rgbToHex(currentRgb[0], currentRgb[1], currentRgb[2]); + const hueColor = rgbToHex(...hsvToRgb(h, 1, 1)); + + const saturationRef = React.useRef(null); + const hueRef = React.useRef(null); + const alphaRef = React.useRef(null); + + const [hexInput, setHexInput] = React.useState(currentHex); + const [hexError, setHexError] = React.useState(false); + const alphaValue = alpha === 1 ? "1" : alpha.toFixed(2); + const [alphaInput, setAlphaInput] = React.useState(alphaValue); + + React.useEffect(() => { + setHexInput(currentHex); + setHexError(false); + }, [currentHex]); + + React.useEffect(() => { + setAlphaInput(alphaValue); + }, [alphaValue]); + + const updateColor = (newColor: HsvaColor) => { + const [r, g, b] = hsvToRgb(newColor.h, newColor.s, newColor.v); + if (newColor.alpha < 1) { + onChange(`rgba(${r}, ${g}, ${b}, ${newColor.alpha.toFixed(2)})`); + return; + } + onChange(rgbToHex(r, g, b)); + }; + + // Handle saturation/brightness picker drag + const handleSaturationMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + const handleMove = (e: MouseEvent) => { + if (!saturationRef.current) { + return; + } + const rect = saturationRef.current.getBoundingClientRect(); + const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); + updateColor({ h, s: x, v: 1 - y, alpha }); + }; + + const handleUp = () => { + window.removeEventListener("mousemove", handleMove); + window.removeEventListener("mouseup", handleUp); + }; + + handleMove(e.nativeEvent as unknown as MouseEvent); + window.addEventListener("mousemove", handleMove); + window.addEventListener("mouseup", handleUp); + }; + + // Handle hue slider drag + const handleHueMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + const handleMove = (e: MouseEvent) => { + if (!hueRef.current) { + return; + } + const rect = hueRef.current.getBoundingClientRect(); + const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + updateColor({ h: x * 360, s, v, alpha }); + }; + + const handleUp = () => { + window.removeEventListener("mousemove", handleMove); + window.removeEventListener("mouseup", handleUp); + }; + + handleMove(e.nativeEvent as unknown as MouseEvent); + window.addEventListener("mousemove", handleMove); + window.addEventListener("mouseup", handleUp); + }; + + // Handle alpha slider drag + const handleAlphaMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + const handleMove = (e: MouseEvent) => { + if (!alphaRef.current) { + return; + } + const rect = alphaRef.current.getBoundingClientRect(); + const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + updateColor({ h, s, v, alpha: Math.round(x * 100) / 100 }); + }; + + const handleUp = () => { + window.removeEventListener("mousemove", handleMove); + window.removeEventListener("mouseup", handleUp); + }; + + handleMove(e.nativeEvent as unknown as MouseEvent); + window.addEventListener("mousemove", handleMove); + window.addEventListener("mouseup", handleUp); + }; + + const handleHexCommit = () => { + const result = hexColorSchema.safeParse(hexInput); + if (!result.success) { + setHexError(true); + return; + } + + const rgb = hexToRgb(result.data); + if (!rgb) { + setHexError(true); + return; + } + + const [newH, newS, newV] = rgbToHsv(rgb[0], rgb[1], rgb[2]); + setHexInput(result.data.toLowerCase()); + setHexError(false); + updateColor({ h: newH, s: newS, v: newV, alpha }); + }; + + const handleHexKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + (e.target as HTMLInputElement).blur(); + } + if (e.key === "Escape") { + setHexInput(currentHex); + setHexError(false); + } + }; + + const handleAlphaCommit = () => { + const value = Number(alphaInput); + if (!Number.isFinite(value)) { + setAlphaInput(alphaValue); + return; + } + + const nextAlpha = Math.round(Math.max(0, Math.min(1, value)) * 100) / 100; + setAlphaInput(nextAlpha === 1 ? "1" : nextAlpha.toFixed(2)); + updateColor({ h, s, v, alpha: nextAlpha }); + }; + + const handleAlphaKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + (e.target as HTMLInputElement).blur(); + } + if (e.key === "Escape") { + setAlphaInput(alphaValue); + } + }; + + return ( + + + + + +
+ {/* Saturation/Brightness picker */} + {/** biome-ignore lint/a11y/noNoninteractiveElementInteractions: + {/* Picker handle */} +
+
+ + {/* Hex input */} +
+ + HEX + + { + setHexInput(e.target.value); + if (hexError) { + setHexError(false); + } + }} + onKeyDown={handleHexKeyDown} + value={hexInput} + /> +
+ + {/* RGBA inputs */} +
+
+ + R + + { + const r = Math.max( + 0, + Math.min(255, Math.trunc(Number(e.target.value) || 0)) + ); + const [newH, newS, newV] = rgbToHsv( + r, + currentRgb[1], + currentRgb[2] + ); + updateColor({ h: newH, s: newS, v: newV, alpha }); + }} + step={1} + type="number" + value={currentRgb[0]} + /> +
+
+ + G + + { + const g = Math.max( + 0, + Math.min(255, Math.trunc(Number(e.target.value) || 0)) + ); + const [newH, newS, newV] = rgbToHsv( + currentRgb[0], + g, + currentRgb[2] + ); + updateColor({ h: newH, s: newS, v: newV, alpha }); + }} + step={1} + type="number" + value={currentRgb[1]} + /> +
+
+ + B + + { + const b = Math.max( + 0, + Math.min(255, Math.trunc(Number(e.target.value) || 0)) + ); + const [newH, newS, newV] = rgbToHsv( + currentRgb[0], + currentRgb[1], + b + ); + updateColor({ h: newH, s: newS, v: newV, alpha }); + }} + step={1} + type="number" + value={currentRgb[2]} + /> +
+
+ + A + + { + setAlphaInput(e.target.value); + }} + onKeyDown={handleAlphaKeyDown} + step={0.01} + type="number" + value={alphaInput} + /> +
+
+ + {/* Hue slider */} + {/** biome-ignore lint/a11y/noNoninteractiveElementInteractions: +
+
+ + {/* Alpha slider */} + {/** biome-ignore lint/a11y/noNoninteractiveElementInteractions: +
+
+
+
+
+ ); +} diff --git a/apps/dashboard/components/ui/segmented-control.tsx b/apps/dashboard/components/ui/segmented-control.tsx new file mode 100644 index 0000000..e7dc2ae --- /dev/null +++ b/apps/dashboard/components/ui/segmented-control.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { cn } from "@castfy/ui/lib/utils"; +import type React from "react"; + +interface SegmentedControlOption { + ariaLabel?: string; + icon?: React.ReactNode; + id: string; + label?: string; +} + +interface SegmentedControlProps { + className?: string; + indicatorClassName?: string; + onChange: (value: string) => void; + options: SegmentedControlOption[]; + size?: "sm" | "md"; + value: string; +} + +export function SegmentedControl({ + options, + value, + onChange, + className, + indicatorClassName, + size = "md", +}: SegmentedControlProps) { + const activeIndex = options.findIndex((o) => o.id === value); + + return ( +
+
+ {options.map((option) => ( + + ))} +
+ ); +} diff --git a/apps/dashboard/components/ui/slider.tsx b/apps/dashboard/components/ui/slider.tsx new file mode 100644 index 0000000..f5efc27 --- /dev/null +++ b/apps/dashboard/components/ui/slider.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { cn } from "@castfy/ui/lib/utils"; +// biome-ignore lint/performance/noNamespaceImport: { + label?: string; + valueDisplay?: string | number; +} + +function Slider({ + className, + defaultValue, + value, + min = 0, + max = 100, + label, + valueDisplay, + ...props +}: SliderProps) { + const _values = React.useMemo( + () => + Array.isArray(value) + ? value + : Array.isArray(defaultValue) + ? defaultValue + : [min, max], + [value, defaultValue, min, max] + ); + + const displayValue = + valueDisplay ?? + (Array.isArray(value) + ? value[0] + : (value ?? + (Array.isArray(defaultValue) + ? defaultValue[0] + : (defaultValue ?? min)))); + + return ( +
+ {/* Label and value overlaid inside the slider */} + {(label || displayValue !== undefined) && ( +
+ {label && ( + {label} + )} + + {displayValue} + +
+ )} + + + + + {Array.from({ length: _values.length }, (_, index) => ( + + ))} + +
+ ); +} + +export { Slider }; diff --git a/apps/dashboard/features/app/_layout/disconnected.tsx b/apps/dashboard/features/app/_layout/disconnected.tsx new file mode 100644 index 0000000..82f714e --- /dev/null +++ b/apps/dashboard/features/app/_layout/disconnected.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { LoaderIcon } from "lucide-react"; +import { useOffline } from "next/offline"; + +export default function Disconnected() { + const isOffline = useOffline(); + + if (!isOffline) { + return null; + } + return ( +
+ +
+

Reconnecting.

+

Just a moment...

+
+
+ ); +} diff --git a/apps/dashboard/features/app/demo/header.tsx b/apps/dashboard/features/app/demo/header.tsx index df60d6e..59db00f 100644 --- a/apps/dashboard/features/app/demo/header.tsx +++ b/apps/dashboard/features/app/demo/header.tsx @@ -1,11 +1,11 @@ import { Button } from "@castfy/ui/components/button"; - import { DemoDropMenu } from "./menu"; export default function DemoHeader() { return (
+
Notion demo diff --git a/apps/dashboard/features/app/demo/sidebar/agent.tsx b/apps/dashboard/features/app/demo/sidebar/agent/index.tsx similarity index 100% rename from apps/dashboard/features/app/demo/sidebar/agent.tsx rename to apps/dashboard/features/app/demo/sidebar/agent/index.tsx diff --git a/apps/dashboard/features/app/demo/sidebar/background.tsx b/apps/dashboard/features/app/demo/sidebar/background.tsx deleted file mode 100644 index 0488c60..0000000 --- a/apps/dashboard/features/app/demo/sidebar/background.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@castfy/ui/components/accordion"; - -const backgrounds = [ - { - value: "light-shadow", - title: "Light & Shadow", - content: "Light & Shadow options", - }, - { - value: "custom", - title: "Custom", - content: "Custom options", - }, - { - value: "abstract", - title: "Abstract", - content: "Abstract options", - }, - { - value: "mac-os", - title: "MacOs", - content: "MacOs options", - }, - { - value: "radiant", - title: "Radiant", - content: "Radiant options", - }, - { - value: "mesh", - title: "Mesh", - content: "Mesh options", - }, - { - value: "raycast", - title: "Raycast", - content: "Raycast options", - }, - { - value: "paper", - title: "Paper", - content: "Paper options", - }, - { - value: "pattern", - title: "Pattern", - content: "Pattern options", - }, - { - value: "gradient", - title: "Gradient", - content: "Gradient options", - }, -]; - -export function BackgroundTab() { - return ( -
- - {backgrounds.map((background) => ( - - - {background.title} - - {background.content} - - ))} - -
- ); -} diff --git a/apps/dashboard/features/app/demo/sidebar/background/index.tsx b/apps/dashboard/features/app/demo/sidebar/background/index.tsx new file mode 100644 index 0000000..5a6a6e1 --- /dev/null +++ b/apps/dashboard/features/app/demo/sidebar/background/index.tsx @@ -0,0 +1,523 @@ +/** biome-ignore-all lint/performance/noImgElement: `/overlay-shadow/${id}.webp` +); + +// Category display names (ordered) +const CATEGORY_ORDER = [ + "assets", + "mac", + "radiant", + "mesh", + "raycast", + "paper", + "pattern", +] as const; +const CATEGORY_LABELS: Record = { + assets: "Abstract", + mac: "macOS", + radiant: "Radiant", + mesh: "Mesh", + raycast: "Raycast", + paper: "Paper", + pattern: "Pattern", +}; + +export function BackgroundTab() { + const { + backgroundConfig, + imageOverlays, + setBackgroundType, + setBackgroundValue, + addImageOverlay, + removeImageOverlay, + } = useImageStore(); + + const responsiveDimensions = useResponsiveCanvasDimensions(); + const [bgUploadError, setBgUploadError] = React.useState(null); + const [customColor, setCustomColor] = React.useState("#7dd4ad"); + + // Track which custom bg option is active + const customBgType = React.useMemo(() => { + if ( + backgroundConfig.type === "solid" && + backgroundConfig.value === "transparent" + ) { + return "transparent"; + } + if ( + backgroundConfig.type === "solid" && + backgroundConfig.value?.startsWith("#") + ) { + return "color"; + } + if ( + backgroundConfig.type === "solid" && + backgroundConfig.value?.startsWith("rgba") + ) { + return "color"; + } + if ( + backgroundConfig.type === "image" && + backgroundConfig.value?.startsWith("blob:") + ) { + return "image"; + } + return null; + }, [backgroundConfig]); + + const validateFile = (file: File): string | null => { + if (!ALLOWED_IMAGE_TYPES.includes(file.type)) { + return "File type not supported. Please use: PNG, JPG, WEBP"; + } + if (file.size > MAX_IMAGE_SIZE) { + return `File size too large. Maximum size is ${MAX_IMAGE_SIZE / 1024 / 1024}MB`; + } + return null; + }; + + // biome-ignore lint/correctness/useExhaustiveDependencies: { + if (acceptedFiles.length > 0) { + const file = acceptedFiles[0]; + const validationError = validateFile(file); + if (validationError) { + setBgUploadError(validationError); + return; + } + setBgUploadError(null); + const blobUrl = URL.createObjectURL(file); + setBackgroundValue(blobUrl); + setBackgroundType("image"); + } + }, + [setBackgroundValue, setBackgroundType] + ); + + const { getRootProps: getBgRootProps, getInputProps: getBgInputProps } = + useDropzone({ + onDrop: onBgDrop, + accept: { + "image/*": ALLOWED_IMAGE_TYPES.map((type) => type.split("/")[1]), + }, + maxSize: MAX_IMAGE_SIZE, + multiple: false, + }); + + // Overlay helpers + const getFullCanvasOverlay = () => { + const canvasWidth = responsiveDimensions.width || 1920; + const canvasHeight = responsiveDimensions.height || 1080; + return { + x: canvasWidth / 2, + y: canvasHeight / 2, + size: Math.max(canvasWidth, canvasHeight), + }; + }; + + const handleAddShadow = (shadowUrl: string) => { + // Remove any existing shadows first (only one shadow at a time) + for (const overlay of imageOverlays) { + if ( + typeof overlay.src === "string" && + overlay.src.includes("overlay-shadow") + ) { + removeImageOverlay(overlay.id); + } + } + + // Add the new shadow + const { x, y, size } = getFullCanvasOverlay(); + addImageOverlay({ + src: shadowUrl, + position: { x, y }, + size, + rotation: 0, + opacity: 0.5, + flipX: false, + flipY: false, + isVisible: true, + }); + }; + + const handleRemoveShadows = () => { + for (const overlay of imageOverlays) { + if ( + typeof overlay.src === "string" && + overlay.src.includes("overlay-shadow") + ) { + removeImageOverlay(overlay.id); + } + } + }; + + // Get current active shadow + const currentShadow = imageOverlays.find( + (overlay) => + typeof overlay.src === "string" && overlay.src.includes("overlay-shadow") + ); + + const availableCategories = CATEGORY_ORDER.filter( + (cat) => backgroundCategories[cat]?.length > 0 + ); + + return ( +
+ + + + Light Shadow + + +
+ + {OVERLAY_SHADOW_URLS.slice(0, 11).map((shadowUrl, index) => ( + + ))} +
+
+
+ + + Custom Background + + +
+ {/* Image Upload */} +
+ +
+ +
+ + Image + +
+ + {/* Color Picker */} + { + setCustomColor(newColor); + setBackgroundType("solid"); + setBackgroundValue(newColor); + }} + /> + + {/* Transparent */} + +
+ {bgUploadError && ( +

{bgUploadError}

+ )} + + {/* Current Image Preview */} + {backgroundConfig.type === "image" && + backgroundConfig.value?.startsWith("blob:") && ( +
+ Background + +
+ )} +
+
+ + {availableCategories.map((category) => ( + + + {CATEGORY_LABELS[category] || category} + + +
+ {(backgroundCategories[category] || []).map( + (imagePath: string, idx: number) => ( + + ) + )} +
+
+
+ ))} + + + + Magic Gradients + + +
+
+ {(Object.keys(magicGradients) as MagicGradientKey[]).map( + (key, idx) => ( +
+
+
+
+ + + Gradients + + +
+ {/* Classic Gradients */} + {(Object.keys(gradientColors) as GradientKey[]).map((key) => ( +
+
+
+
+
+ ); +} diff --git a/apps/dashboard/features/app/demo/sidebar/design/border-selection.tsx b/apps/dashboard/features/app/demo/sidebar/design/border-selection.tsx new file mode 100644 index 0000000..730fac8 --- /dev/null +++ b/apps/dashboard/features/app/demo/sidebar/design/border-selection.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { cn } from "@castfy/ui/lib/utils"; +import { Slider } from "@/components/ui/slider"; +import { useImageStore } from "@/lib/store"; + +const borderPresets = [ + { value: 0, label: "Sharp" }, + { value: 12, label: "Curved" }, + { value: 20, label: "Round" }, +] as const; + +function BorderPreview({ + radius, + selected, +}: { + radius: number; + selected: boolean; +}) { + const previewRadius = radius === 0 ? "0px" : radius === 12 ? "6px" : "12px"; + + return ( +
+
+
+
+
+ ); +} + +function ScaleSlider() { + const imageScale = useImageStore((s) => s.imageScale); + const setImageScale = useImageStore((s) => s.setImageScale); + + return ( + setImageScale(Math.round(value[0] * 100))} + step={0.01} + value={[imageScale / 100]} + valueDisplay={(imageScale / 100).toFixed(1)} + /> + ); +} + +export function BorderSection() { + const borderRadius = useImageStore((s) => s.borderRadius); + const setBorderRadius = useImageStore((s) => s.setBorderRadius); + + return ( +
+
+ {borderPresets.map(({ value, label }) => { + const isSelected = borderRadius === value; + return ( + + ); + })} +
+ + setBorderRadius(value[0])} + step={1} + value={[borderRadius]} + valueDisplay={borderRadius} + /> + +
+ ); +} diff --git a/apps/dashboard/features/app/demo/sidebar/design/browser-mockup.tsx b/apps/dashboard/features/app/demo/sidebar/design/browser-mockup.tsx new file mode 100644 index 0000000..f03567a --- /dev/null +++ b/apps/dashboard/features/app/demo/sidebar/design/browser-mockup.tsx @@ -0,0 +1,266 @@ +"use client"; + +import { cn } from "@castfy/ui/lib/utils"; +import { Slider } from "@/components/ui/slider"; +import { useImageStore } from "@/lib/store"; + +type BrowserStyle = "safari" | "safari-dark" | "chrome" | "chrome-dark"; + +const browserStyles: { + value: BrowserStyle; + label: string; + frameType: "macos-light" | "macos-dark" | "windows-light" | "windows-dark"; +}[] = [ + { value: "safari", label: "Safari", frameType: "macos-light" }, + { value: "safari-dark", label: "Safari Dark", frameType: "macos-dark" }, + { value: "chrome", label: "Chrome", frameType: "windows-light" }, + { value: "chrome-dark", label: "Chrome Dark", frameType: "windows-dark" }, +]; + +const frameToStyle: Record = { + "macos-light": "safari", + "macos-dark": "safari-dark", + "windows-light": "chrome", + "windows-dark": "chrome-dark", +}; + +function BrowserPreview({ + style, + selected, +}: { + style: BrowserStyle; + selected: boolean; +}) { + const isDark = style === "safari-dark" || style === "chrome-dark"; + const isSafari = style === "safari" || style === "safari-dark"; + + const titleBarBg = isDark + ? isSafari + ? "#3A3A3C" + : "#202124" + : isSafari + ? "#F6F6F6" + : "#DEE1E6"; + const activeBg = isDark ? "#292A2D" : "#FFFFFF"; + const contentBg = isDark ? "#1E1E1E" : "#FFFFFF"; + const outerBg = isDark ? "rgb(60, 60, 65)" : "rgb(210, 210, 214)"; + + return ( +
+
+ {isSafari ? ( + <> + {/* Safari: single title bar */} +
+
+
+
+
+ + ) : ( + <> + {/* Chrome: tab bar + address bar */} +
+
+
+
+
+
+
+
+
+ + )} + {/* Content area */} +
+
+
+ ); +} + +export function BrowserMockupSection() { + const { + imageBorder, + setImageBorder, + browserUrl, + setBrowserUrl, + browserHeaderSize, + setBrowserHeaderSize, + } = useImageStore(); + + const currentStyle = frameToStyle[imageBorder.type] || "chrome-dark"; + + const handleStyleChange = (style: BrowserStyle) => { + const config = browserStyles.find((s) => s.value === style); + if (!config) { + return; + } + setImageBorder({ + enabled: true, + type: config.frameType, + title: browserUrl, + }); + }; + + return ( + <> +
+
+ {browserStyles.map(({ value, label }) => { + const isSelected = currentStyle === value; + return ( + + ); + })} +
+
+ +
+ + setBrowserUrl(e.target.value)} + placeholder="yourapp.com" + type="text" + value={browserUrl} + /> +
+ + setBrowserHeaderSize(value[0])} + step={5} + value={[browserHeaderSize]} + valueDisplay={`${browserHeaderSize}%`} + /> + + ); +} diff --git a/apps/dashboard/features/app/demo/sidebar/design.tsx b/apps/dashboard/features/app/demo/sidebar/design/index.tsx similarity index 61% rename from apps/dashboard/features/app/demo/sidebar/design.tsx rename to apps/dashboard/features/app/demo/sidebar/design/index.tsx index e1f2038..2e0facf 100644 --- a/apps/dashboard/features/app/demo/sidebar/design.tsx +++ b/apps/dashboard/features/app/demo/sidebar/design/index.tsx @@ -4,22 +4,31 @@ import { AccordionItem, AccordionTrigger, } from "@castfy/ui/components/accordion"; +import { BorderSection } from "./border-selection"; +import { BrowserMockupSection } from "./browser-mockup"; +import { ShadowSection } from "./shadow-section"; +import { StyleSection } from "./style-selection"; const designs = [ - { - value: "shadow", - title: "Shadow", - content: "shadow options", - }, { value: "style", title: "Style", - content: "style options", + content: , }, { value: "border", title: "Border", - content: "border options", + content: , + }, + { + value: "browser", + title: "Browser", + content: , + }, + { + value: "shadow", + title: "Shadow", + content: , }, ]; export function DesignTab() { @@ -31,7 +40,9 @@ export function DesignTab() { {background.title} - {background.content} + + {background.content} + ))} diff --git a/apps/dashboard/features/app/demo/sidebar/design/shadow-section.tsx b/apps/dashboard/features/app/demo/sidebar/design/shadow-section.tsx new file mode 100644 index 0000000..2d5ec22 --- /dev/null +++ b/apps/dashboard/features/app/demo/sidebar/design/shadow-section.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { cn } from "@castfy/ui/lib/utils"; +import { type ShadowPreset, useImageStore } from "@/lib/store"; + +const shadowPresets: { value: ShadowPreset; label: string; shadow: string }[] = + [ + { value: "none", label: "None", shadow: "none" }, + { + value: "hug", + label: "Hug", + shadow: + "rgba(0,0,0,0.2) 0px 2px 12px 0px, rgba(0,0,0,0.14) 0px 1px 4px 0px", + }, + { + value: "soft", + label: "Soft", + shadow: + "rgba(0,0,0,0.28) 0px 12px 48px 0px, rgba(0,0,0,0.18) 0px 4px 12px 0px", + }, + { + value: "strong", + label: "Strong", + shadow: + "rgba(0,0,0,0.45) 0px 24px 80px 0px, rgba(0,0,0,0.3) 0px 8px 24px 0px", + }, + ]; + +function ShadowPreview({ + shadow, + selected, +}: { + shadow: string; + selected: boolean; +}) { + return ( +
+
+
+ ); +} + +export function ShadowSection() { + const { shadowPreset, setShadowPreset } = useImageStore(); + + return ( +
+ {shadowPresets.map(({ value, label, shadow }) => { + const isSelected = shadowPreset === value; + return ( + + ); + })} +
+ ); +} diff --git a/apps/dashboard/features/app/demo/sidebar/design/style-selection.tsx b/apps/dashboard/features/app/demo/sidebar/design/style-selection.tsx new file mode 100644 index 0000000..440b702 --- /dev/null +++ b/apps/dashboard/features/app/demo/sidebar/design/style-selection.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { cn } from "@castfy/ui/lib/utils"; +import type * as React from "react"; +import { Slider } from "@/components/ui/slider"; +import { type ImageStylePreset, useImageStore } from "@/lib/store"; + +const stylePresets: { value: ImageStylePreset; label: string }[] = [ + { value: "default", label: "Default" }, + { value: "glass-light", label: "Glass Light" }, + { value: "glass-dark", label: "Glass Dark" }, + { value: "outline", label: "Outline" }, + { value: "border-light", label: "Border" }, + { value: "border-dark", label: "Border Dark" }, +]; + +function StylePreview({ + preset, + selected, +}: { + preset: ImageStylePreset; + selected: boolean; +}) { + const isDark = preset === "glass-dark" || preset === "border-dark"; + const outerBg = isDark ? "rgb(160, 160, 165)" : "rgb(210, 210, 214)"; + + const getWrapperStyle = (): React.CSSProperties => { + switch (preset) { + case "default": + return {}; + case "glass-light": + return { + background: "rgba(255, 255, 255, 0.3)", + padding: "3px", + borderRadius: "7px", + }; + case "glass-dark": + return { + background: "rgba(0, 0, 0, 0.35)", + padding: "3px", + borderRadius: "7px", + }; + case "outline": + return { + background: "rgba(255, 255, 255, 0.4)", + padding: "2px", + borderRadius: "7px", + }; + case "border-light": + return { + background: "rgb(255, 255, 255)", + padding: "4px", + borderRadius: "8px", + }; + case "border-dark": + return { + background: "rgb(30, 30, 30)", + padding: "4px", + borderRadius: "8px", + }; + default: + return {}; + } + }; + + const hasWrapper = preset !== "default"; + + return ( +
+
+ {hasWrapper ? ( +
+
+
+ ) : ( +
+ )} +
+
+ ); +} + +export function StyleSection() { + const { imageStylePreset, setImageStylePreset, imageBorder, setImageBorder } = + useImageStore(); + + const isNonDefault = imageStylePreset !== "default"; + const currentOpacity = imageBorder.opacity ?? 0.3; + const currentPadding = imageBorder.padding ?? 2; + + return ( +
+
+ {stylePresets.map(({ value, label }) => { + const isSelected = imageStylePreset === value; + return ( + + ); + })} +
+ + {isNonDefault && ( +
+ setImageBorder({ padding: value[0] })} + step={0.5} + value={[currentPadding]} + valueDisplay={currentPadding.toFixed(1)} + /> + + setImageBorder({ opacity: value[0] / 100 }) + } + step={1} + value={[Math.round(currentOpacity * 100)]} + valueDisplay={`${Math.round(currentOpacity * 100)}%`} + /> +
+ )} +
+ ); +} diff --git a/apps/dashboard/features/app/demo/sidebar/index.tsx b/apps/dashboard/features/app/demo/sidebar/index.tsx index aacb25a..06085a0 100644 --- a/apps/dashboard/features/app/demo/sidebar/index.tsx +++ b/apps/dashboard/features/app/demo/sidebar/index.tsx @@ -53,7 +53,10 @@ export default function DemoSidebar({ className }: { className?: string }) { - + diff --git a/apps/dashboard/features/app/demo/video-editor/aspect-ratio.tsx b/apps/dashboard/features/app/demo/video-editor/aspect-ratio.tsx index 61e38ed..edaf03f 100644 --- a/apps/dashboard/features/app/demo/video-editor/aspect-ratio.tsx +++ b/apps/dashboard/features/app/demo/video-editor/aspect-ratio.tsx @@ -2,41 +2,46 @@ import { Button } from "@castfy/ui/components/button"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuShortcut, - DropdownMenuTrigger, -} from "@castfy/ui/components/dropdown-menu"; -import { CheckIcon, ChevronDownIcon } from "lucide-react"; + Popover, + PopoverContent, + PopoverTrigger, +} from "@castfy/ui/components/popover"; +import { AspectRatioIcon } from "hugeicons-react"; +import { useState } from "react"; +import { AspectRatioPicker } from "@/components/aspect-ratio/aspect-ratio-picker"; +import { aspectRatios } from "@/lib/constants/aspect-ratios"; +import { useImageStore } from "@/lib/store"; export function AspectRatio() { + const { selectedAspectRatio } = useImageStore(); + const [aspectRatioOpen, setAspectRatioOpen] = useState(false); + const currentAspectRatio = aspectRatios.find( + (ar) => ar.id === selectedAspectRatio + ); return ( - - - - - - - - 16:9 - - - - - - - 9:16 - - - 1:1 - - - - + + + setAspectRatioOpen(false)} /> + + ); } diff --git a/apps/dashboard/features/app/demo/video-editor/footer.tsx b/apps/dashboard/features/app/demo/video-editor/footer.tsx index fd21aa2..cffa5a0 100644 --- a/apps/dashboard/features/app/demo/video-editor/footer.tsx +++ b/apps/dashboard/features/app/demo/video-editor/footer.tsx @@ -1,9 +1,10 @@ import { Button } from "@castfy/ui/components/button"; import { PlayIcon, - ScissorsIcon, + Redo2Icon, SkipBackIcon, SkipForwardIcon, + Undo2Icon, Volume2Icon, ZoomInIcon, ZoomOutIcon, @@ -33,7 +34,14 @@ export function EditorFooter() { size="icon" variant={"ghost"} > - + + +
diff --git a/apps/dashboard/features/app/demo/video-editor/index.tsx b/apps/dashboard/features/app/demo/video-editor/index.tsx index e6d6a93..582362d 100644 --- a/apps/dashboard/features/app/demo/video-editor/index.tsx +++ b/apps/dashboard/features/app/demo/video-editor/index.tsx @@ -1,11 +1,12 @@ import { EditorFooter } from "./footer"; -import { EditorVideo } from "./video"; +import { StudioCanvas } from "./studio-canvas"; export default function AppVideoEditor() { return ( -
+
+ {/**/} - + {/* */}
); diff --git a/apps/dashboard/features/app/demo/video-editor/studio-canvas.tsx b/apps/dashboard/features/app/demo/video-editor/studio-canvas.tsx new file mode 100644 index 0000000..28e220c --- /dev/null +++ b/apps/dashboard/features/app/demo/video-editor/studio-canvas.tsx @@ -0,0 +1,113 @@ +"use client"; + +import dynamic from "next/dynamic"; +import React from "react"; +import { CleanUploadState } from "@/components/controls/CleanUploadState"; +import { aspectRatios } from "@/lib/constants/aspect-ratios"; +import { useEditorStore, useImageStore } from "@/lib/store"; + +const ClientCanvas = dynamic(() => import("@/components/canvas/ClientCanvas"), { + ssr: false, + loading: () => ( +
+
+
+ ), +}); + +export function StudioCanvas() { + const { screenshot } = useEditorStore(); + const { + slides, + setActiveSlide, + activeSlideId, + removeSlide, + previewIndex, + isPreviewing, + stopPreview, + uploadedImageUrl, + showTimeline, + selectedAspectRatio, + } = useImageStore(); + + // Check both stores - imageStore is the source of truth (tracked by undo/redo) + const hasImage = !!uploadedImageUrl && !!screenshot.src; + + React.useEffect(() => { + if (!isPreviewing) { + return; + } + if (slides.length === 0) { + stopPreview(); + return; + } + + if (previewIndex >= slides.length) { + stopPreview(); + return; + } + + const slide = slides[previewIndex]; + setActiveSlide(slide.id); + + const timer = setTimeout(() => { + useImageStore.setState((state) => { + if (state.previewIndex + 1 >= state.slides.length) { + return { + isPreviewing: false, + previewIndex: 0, + }; + } + + return { + previewIndex: state.previewIndex + 1, + }; + }); + }, slide.duration * 1000); + + return () => clearTimeout(timer); + }, [isPreviewing, previewIndex, setActiveSlide, slides, stopPreview]); + + // Show upload state if no image in either store + if (!hasImage) { + const currentRatio = aspectRatios.find( + (ar) => ar.id === selectedAspectRatio + ); + const ratioValue = currentRatio + ? currentRatio.width / currentRatio.height + : 16 / 9; + + return ( +
+
+
+ + {/* */} + {/* cleam */} +
+
+
+ ); + } + + return ( +
+ {/* */} + +
+ +
+
+ ); +} diff --git a/apps/dashboard/hooks/use-aspect-ratio-dimensions.ts b/apps/dashboard/hooks/use-aspect-ratio-dimensions.ts new file mode 100644 index 0000000..141644c --- /dev/null +++ b/apps/dashboard/hooks/use-aspect-ratio-dimensions.ts @@ -0,0 +1,137 @@ +/** + * Hook for getting aspect ratio dimensions + * Provides reactive dimensions based on selected aspect ratio + */ + +import { useEffect, useMemo, useState } from "react"; +import { + calculateFitDimensions, + getAspectRatioCSS, + getAspectRatioPreset, +} from "@/lib/aspect-ratio-utils"; +import { useImageStore } from "@/lib/store"; + +/** + * Hook to get canvas dimensions based on selected aspect ratio + * Returns dimensions that fit within viewport constraints + */ +export function useAspectRatioDimensions(options?: { + maxWidth?: number; + maxHeight?: number; +}) { + const { selectedAspectRatio } = useImageStore(); + + const dimensions = useMemo(() => { + const preset = getAspectRatioPreset(selectedAspectRatio); + if (!preset) { + return { width: 1920, height: 1080, aspectRatio: "16/9" }; + } + + const { maxWidth, maxHeight } = options || {}; + + // If constraints provided, calculate fit dimensions + if (maxWidth || maxHeight) { + const fitDimensions = calculateFitDimensions( + preset.width, + preset.height, + maxWidth, + maxHeight + ); + return { + ...fitDimensions, + aspectRatio: getAspectRatioCSS(preset.width, preset.height), + originalWidth: preset.width, + originalHeight: preset.height, + }; + } + + // Return original dimensions + return { + width: preset.width, + height: preset.height, + aspectRatio: getAspectRatioCSS(preset.width, preset.height), + originalWidth: preset.width, + originalHeight: preset.height, + }; + }, [selectedAspectRatio, options]); + + return dimensions; +} + +/** + * Hook to get responsive canvas dimensions that fit within viewport + * Automatically calculates max dimensions based on viewport size + * Reactively updates when window is resized + */ +export function useResponsiveCanvasDimensions() { + const { selectedAspectRatio } = useImageStore(); + const [viewportSize, setViewportSize] = useState({ + width: 1920, + height: 1080, + }); + + // Track viewport size changes + useEffect(() => { + const updateViewportSize = () => { + setViewportSize({ + width: window.innerWidth, + height: window.innerHeight, + }); + }; + + // Set initial size + updateViewportSize(); + + // Listen for resize events + window.addEventListener("resize", updateViewportSize); + return () => window.removeEventListener("resize", updateViewportSize); + }, []); + + const dimensions = useMemo(() => { + const preset = getAspectRatioPreset(selectedAspectRatio); + if (!preset) { + return { width: 1920, height: 1080, aspectRatio: "16/9" }; + } + + // Determine layout constraints based on viewport size. + // On mobile the side panels are hidden inside sheets, so we should not + // subtract their desktop width (otherwise calculations go negative and + // the canvas collapses). This keeps the preview and export canvases in sync. + const MOBILE_BREAKPOINT = 768; + const isMobileViewport = viewportSize.width < MOBILE_BREAKPOINT; + const sidePanelsWidth = isMobileViewport ? 0 : 640; // left + right panels on desktop + const horizontalPadding = isMobileViewport ? 32 : 48; // reduce padding on small screens + const verticalPadding = isMobileViewport ? 140 : 200; // header + footer allowance + + const rawAvailableWidth = + viewportSize.width - sidePanelsWidth - horizontalPadding; + const rawAvailableHeight = viewportSize.height - verticalPadding; + + // Prevent negative/too-small values so fit calculations remain stable. + const MIN_AVAILABLE = 320; + const availableWidth = Math.max(rawAvailableWidth, MIN_AVAILABLE); + const availableHeight = Math.max(rawAvailableHeight, MIN_AVAILABLE); + + // Allow a little breathing room on larger screens without over-scaling mobile. + const widthScale = isMobileViewport ? 1 : 1.1; + const heightScale = isMobileViewport ? 1 : 1.1; + const maxWidth = Math.min(availableWidth * widthScale, 3000); + const maxHeight = Math.min(availableHeight * heightScale, 1500); + + const fitDimensions = calculateFitDimensions( + preset.width, + preset.height, + maxWidth, + maxHeight + ); + + return { + ...fitDimensions, + aspectRatio: getAspectRatioCSS(preset.width, preset.height), + originalWidth: preset.width, + originalHeight: preset.height, + }; + }, [selectedAspectRatio, viewportSize.width, viewportSize.height]); + + return dimensions; +} diff --git a/apps/dashboard/lib/aspect-ratio-utils.ts b/apps/dashboard/lib/aspect-ratio-utils.ts new file mode 100644 index 0000000..7afc99c --- /dev/null +++ b/apps/dashboard/lib/aspect-ratio-utils.ts @@ -0,0 +1,200 @@ +/** + * Aspect Ratio Utilities + * + * Provides standard pixel dimensions for common aspect ratios + * Based on industry standards for social media, video, and design platforms + */ + +import { ASPECT_RATIO_PRESETS, type AspectRatioPreset } from "@/lib/constants"; +import { aspectRatios } from "@/lib/constants/aspect-ratios"; +import { useImageStore } from "@/lib/store"; + +/** + * Standard pixel dimensions mapping for aspect ratios + * These are industry-standard resolutions that maintain aspect ratios + */ +const STANDARD_DIMENSIONS: Record = { + "16:9": { width: 1920, height: 1080 }, // Full HD - Standard for video/YouTube + "1:1": { width: 1080, height: 1080 }, // Square - Instagram feed posts + "4:5": { width: 1080, height: 1350 }, // Portrait - Instagram portrait posts + "9:16": { width: 1080, height: 1920 }, // Story/Reel - Instagram Stories, TikTok + "3:4": { width: 1080, height: 1440 }, // Portrait - Pinterest pins + "2:3": { width: 1200, height: 1800 }, // Portrait - Social media posts + "3:2": { width: 1920, height: 1280 }, // Photo - Standard photography + "4:3": { width: 1920, height: 1440 }, // Traditional - Classic displays + "5:4": { width: 1920, height: 1536 }, // Photo - Classic photography + "16:10": { width: 1920, height: 1200 }, // Widescreen - Desktop displays + "40:21": { width: 1200, height: 630 }, // Open Graph - Standard OG image format (1200×630px) + "3:1": { width: 1500, height: 500 }, // Twitter Banner - Twitter/X profile banner format + "4:1": { width: 1584, height: 396 }, // LinkedIn Banner - LinkedIn profile/company banner format +}; + +// Special dimensions for specific aspect ratio IDs that share common ratios +const SPECIAL_DIMENSIONS: Record = { + youtube_banner: { width: 2560, height: 1440 }, // YouTube Channel Banner - Higher resolution 16:9 + instagram_banner: { width: 1080, height: 1080 }, // Instagram Highlight Cover - Square format + youtube_thumbnail: { width: 1280, height: 720 }, // YouTube Thumbnail + youtube_video: { width: 1920, height: 1080 }, // YouTube Video + pinterest_long: { width: 1000, height: 2100 }, // Pinterest Long Pin + appstore_iphone65: { width: 1284, height: 2778 }, // iPhone 6.5" screenshot + appstore_iphone55: { width: 1242, height: 2208 }, // iPhone 5.5" screenshot + appstore_ipad: { width: 2048, height: 2732 }, // iPad Pro 12.9" screenshot + appstore_iphone65_landscape: { width: 2778, height: 1284 }, // iPhone 6.5" landscape + appstore_iphone55_landscape: { width: 2208, height: 1242 }, // iPhone 5.5" landscape + appstore_ipad_landscape: { width: 2732, height: 2048 }, // iPad Pro 12.9" landscape +}; + +/** + * Get standard pixel dimensions for an aspect ratio + * Returns dimensions that maintain the aspect ratio but use standard sizes + * + * @param width - Aspect ratio width (e.g., 16 for 16:9) + * @param height - Aspect ratio height (e.g., 9 for 16:9) + * @returns Standard pixel dimensions for the aspect ratio + */ +export function getStandardDimensions( + width: number, + height: number +): { width: number; height: number } { + const ratioString = `${width}:${height}`; + + // Check if we have a standard dimension for this ratio + if (STANDARD_DIMENSIONS[ratioString]) { + return STANDARD_DIMENSIONS[ratioString]; + } + + // Calculate ratio + const ratio = width / height; + + // Fallback: scale to a reasonable size maintaining aspect ratio + // Aim for width around 1920px for landscape or 1080px for portrait + if (ratio > 1) { + // Landscape - use Full HD width + return { width: 1920, height: Math.round(1920 / ratio) }; + } + // Portrait - use Full HD height + return { width: Math.round(1080 * ratio), height: 1080 }; +} + +/** + * Get aspect ratio preset from aspect ratio ID + * + * @param aspectRatioId - The ID of the aspect ratio (e.g., '16_9') + * @returns AspectRatioPreset with proper dimensions + */ +export function getAspectRatioPreset( + aspectRatioId: string +): AspectRatioPreset | null { + // Handle custom dimensions from the store + if (aspectRatioId === "custom") { + const customDimensions = useImageStore.getState().customDimensions; + if (customDimensions) { + return { + id: "custom", + name: "Custom", + category: "Custom", + width: customDimensions.width, + height: customDimensions.height, + ratio: `${customDimensions.width}:${customDimensions.height}`, + description: "Custom dimensions", + }; + } + } + + const aspectRatio = aspectRatios.find((ar) => ar.id === aspectRatioId); + + if (!aspectRatio) { + return null; + } + + // Check for special dimensions first (for formats that share ratios but have different dimensions) + if (SPECIAL_DIMENSIONS[aspectRatioId]) { + const specialDimensions = SPECIAL_DIMENSIONS[aspectRatioId]; + const ratioString = `${aspectRatio.width}:${aspectRatio.height}`; + return { + id: aspectRatio.id, + name: aspectRatio.name, + category: aspectRatio.category || "Custom", + width: specialDimensions.width, + height: specialDimensions.height, + ratio: ratioString, + description: aspectRatio.description, + }; + } + + // Find matching preset by comparing ratio strings + const ratioString = `${aspectRatio.width}:${aspectRatio.height}`; + const matchingPreset = ASPECT_RATIO_PRESETS.find( + (preset) => + preset.ratio === ratioString || + preset.ratio === aspectRatio.ratio.toString() + ); + + if (matchingPreset) { + return matchingPreset; + } + + // Create a preset with standard dimensions for this aspect ratio + const standardDimensions = getStandardDimensions( + aspectRatio.width, + aspectRatio.height + ); + return { + id: aspectRatio.id, + name: aspectRatio.name, + category: aspectRatio.category || "Custom", + width: standardDimensions.width, + height: standardDimensions.height, + ratio: ratioString, + description: aspectRatio.description, + }; +} + +/** + * Calculate display dimensions that fit within viewport constraints + * while maintaining aspect ratio + * + * @param width - Original width + * @param height - Original height + * @param maxWidth - Maximum width constraint + * @param maxHeight - Maximum height constraint + * @returns Dimensions that fit within constraints + */ +export function calculateFitDimensions( + width: number, + height: number, + maxWidth?: number, + maxHeight?: number +): { width: number; height: number } { + const ratio = width / height; + + let displayWidth = width; + let displayHeight = height; + + // Apply constraints + if (maxWidth && displayWidth > maxWidth) { + displayWidth = maxWidth; + displayHeight = displayWidth / ratio; + } + + if (maxHeight && displayHeight > maxHeight) { + displayHeight = maxHeight; + displayWidth = displayHeight * ratio; + } + + return { + width: Math.round(displayWidth), + height: Math.round(displayHeight), + }; +} + +/** + * Get CSS aspect ratio string from dimensions + * + * @param width - Width of the aspect ratio + * @param height - Height of the aspect ratio + * @returns CSS aspect ratio string (e.g., "16/9") + */ +export function getAspectRatioCSS(width: number, height: number): string { + return `${width} / ${height}`; +} diff --git a/apps/dashboard/lib/constants.ts b/apps/dashboard/lib/constants.ts index 37b5426..3edd3ae 100644 --- a/apps/dashboard/lib/constants.ts +++ b/apps/dashboard/lib/constants.ts @@ -17,3 +17,164 @@ export const LocalStorageKeys = { }; export const SUPPORT_EMAIL = "support@midday.ai"; + +// Default canvas dimensions +export const DEFAULT_CANVAS_WIDTH = 1920; +export const DEFAULT_CANVAS_HEIGHT = 1080; + +// Image upload limits +export const MAX_IMAGE_SIZE = 100 * 1024 * 1024; // 100MB +export const ALLOWED_IMAGE_TYPES = [ + "image/jpeg", + "image/jpg", + "image/png", + "image/webp", +]; + +// Text defaults +export const DEFAULT_TEXT_FONT_SIZE = 48; +export const DEFAULT_TEXT_COLOR = "#000000"; +export const DEFAULT_FONT_FAMILY = "Arial"; + +// Canvas defaults +export const CANVAS_BACKGROUND_COLOR = "#ffffff"; + +// Aspect Ratio Presets +export interface AspectRatioPreset { + category: string; + description?: string; + height: number; + id: string; + name: string; + ratio: string; // e.g., "1:1", "4:5", "9:16" + width: number; +} + +export const ASPECT_RATIO_PRESETS: AspectRatioPreset[] = [ + // Instagram Formats + { + id: "instagram-square", + name: "Instagram Square", + category: "Instagram", + width: 1080, + height: 1080, + ratio: "1:1", + description: "Perfect for Instagram feed posts", + }, + { + id: "instagram-portrait", + name: "Instagram Portrait", + category: "Instagram", + width: 1080, + height: 1350, + ratio: "4:5", + description: "Portrait format for Instagram feed", + }, + { + id: "instagram-landscape", + name: "Instagram Landscape", + category: "Instagram", + width: 1080, + height: 566, + ratio: "1.91:1", + description: "Landscape format for Instagram feed", + }, + { + id: "instagram-story", + name: "Instagram Story", + category: "Instagram", + width: 1080, + height: 1920, + ratio: "9:16", + description: "Full-screen vertical stories and reels", + }, + { + id: "instagram-reel", + name: "Instagram Reel", + category: "Instagram", + width: 1080, + height: 1920, + ratio: "9:16", + description: "Vertical video format for reels", + }, + + // Common Social Media + { + id: "facebook-post", + name: "Facebook Post", + category: "Facebook", + width: 1200, + height: 630, + ratio: "1.91:1", + description: "Standard Facebook post size", + }, + { + id: "twitter-post", + name: "Twitter/X Post", + category: "Twitter", + width: 1200, + height: 675, + ratio: "16:9", + description: "Standard Twitter post size", + }, + { + id: "youtube-thumbnail", + name: "YouTube Thumbnail", + category: "YouTube", + width: 1280, + height: 720, + ratio: "16:9", + description: "YouTube video thumbnail size", + }, + + // Standard Formats + { + id: "custom", + name: "Custom", + category: "Custom", + width: 1920, + height: 1080, + ratio: "16:9", + description: "Custom dimensions", + }, + { + id: "square", + name: "Square", + category: "Standard", + width: 1080, + height: 1080, + ratio: "1:1", + description: "Square format", + }, + { + id: "portrait-4-3", + name: "Portrait 4:3", + category: "Standard", + width: 1200, + height: 1600, + ratio: "3:4", + description: "Portrait format 3:4", + }, + { + id: "landscape-16-9", + name: "Landscape 16:9", + category: "Standard", + width: 1920, + height: 1080, + ratio: "16:9", + description: "Widescreen landscape format", + }, + { + id: "landscape-21-9", + name: "Ultrawide 21:9", + category: "Standard", + width: 2560, + height: 1080, + ratio: "21:9", + description: "Ultrawide format", + }, +]; + +export const DEFAULT_ASPECT_RATIO = + ASPECT_RATIO_PRESETS.find((p) => p.id === "custom") || + ASPECT_RATIO_PRESETS[0]; diff --git a/apps/dashboard/lib/constants/aspect-ratios.ts b/apps/dashboard/lib/constants/aspect-ratios.ts new file mode 100644 index 0000000..c1066b4 --- /dev/null +++ b/apps/dashboard/lib/constants/aspect-ratios.ts @@ -0,0 +1,278 @@ +export interface AspectRatio { + category: string; + description: string; + height: number; + id: string; + name: string; + ratio: number; + useCase?: string; + width: number; +} + +export const aspectRatios: AspectRatio[] = [ + // Instagram Formats + { + id: "1_1", + name: "Square", + ratio: 1, + width: 1, + height: 1, + category: "Instagram", + description: "Perfect for Instagram feed posts", + useCase: "Instagram Posts", + }, + { + id: "4_5", + name: "Portrait", + ratio: 4 / 5, + width: 4, + height: 5, + category: "Instagram", + description: "Portrait format for Instagram feed", + useCase: "Instagram Posts", + }, + { + id: "9_16", + name: "Story/Reel", + ratio: 9 / 16, + width: 9, + height: 16, + category: "Instagram", + description: "Full-screen vertical stories and reels", + useCase: "Instagram Stories, Reels, TikTok", + }, + + // Social Media + { + id: "16_9", + name: "Landscape", + ratio: 16 / 9, + width: 16, + height: 9, + category: "Social Media", + description: "Widescreen format for videos and posts", + useCase: "YouTube, Twitter/X, Facebook Posts", + }, + { + id: "3_4", + name: "Portrait", + ratio: 3 / 4, + width: 3, + height: 4, + category: "Social Media", + description: "Vertical format for Pinterest and social posts", + useCase: "Pinterest Pins, Social Posts", + }, + { + id: "2_3", + name: "Portrait", + ratio: 2 / 3, + width: 2, + height: 3, + category: "Social Media", + description: "Tall vertical format", + useCase: "Social Media Posts", + }, + { + id: "og_image", + name: "Open Graph", + ratio: 1200 / 630, + width: 40, + height: 21, + category: "Social Media", + description: "Standard Open Graph image format (1200×630px)", + useCase: "Open Graph Images, Facebook, LinkedIn, Twitter/X Cards", + }, + { + id: "twitter_banner", + name: "Twitter Banner", + ratio: 3 / 1, + width: 3, + height: 1, + category: "Social Media", + description: "Twitter/X profile banner format", + useCase: "Twitter/X Profile Banners", + }, + { + id: "instagram_banner", + name: "Instagram Banner", + ratio: 1, + width: 1, + height: 1, + category: "Instagram", + description: "Instagram highlight cover format (1080×1080px)", + useCase: "Instagram Highlight Covers", + }, + { + id: "youtube_banner", + name: "YouTube Banner", + ratio: 16 / 9, + width: 16, + height: 9, + category: "Social Media", + description: "YouTube channel banner format (2560×1440px)", + useCase: "YouTube Channel Banners", + }, + { + id: "linkedin_banner", + name: "LinkedIn Banner", + ratio: 4 / 1, + width: 4, + height: 1, + category: "Social Media", + description: "LinkedIn profile/company banner format (1584×396px)", + useCase: "LinkedIn Profile & Company Banners", + }, + + // Standard Formats + { + id: "3_2", + name: "Photo", + ratio: 3 / 2, + width: 3, + height: 2, + category: "Standard", + description: "Standard photo format", + useCase: "Photography", + }, + { + id: "4_3", + name: "Traditional", + ratio: 4 / 3, + width: 4, + height: 3, + category: "Standard", + description: "Traditional display format", + useCase: "Traditional Displays", + }, + { + id: "5_4", + name: "Photo", + ratio: 5 / 4, + width: 5, + height: 4, + category: "Standard", + description: "Classic photo format", + useCase: "Photography", + }, + { + id: "16_10", + name: "Widescreen", + ratio: 16 / 10, + width: 16, + height: 10, + category: "Standard", + description: "Widescreen desktop format", + useCase: "Desktop Displays", + }, + + // YouTube Specific + { + id: "youtube_thumbnail", + name: "YouTube Thumbnail", + ratio: 16 / 9, + width: 16, + height: 9, + category: "YouTube", + description: "YouTube video thumbnail (1280×720px)", + useCase: "YouTube Thumbnails", + }, + { + id: "youtube_video", + name: "YouTube Video", + ratio: 16 / 9, + width: 16, + height: 9, + category: "YouTube", + description: "YouTube video format (1920×1080px)", + useCase: "YouTube Videos", + }, + + // Pinterest + { + id: "pinterest_long", + name: "Long Pin", + ratio: 10 / 21, + width: 10, + height: 21, + category: "Pinterest", + description: "Long Pinterest pin format (1000×2100px)", + useCase: "Pinterest Long Pins", + }, + + // App Store Screenshots + { + id: "appstore_iphone65", + name: 'iPhone 6.5"', + ratio: 1284 / 2778, + width: 1284, + height: 2778, + category: "App Store", + description: 'iPhone 6.5" screenshot', + useCase: "App Store Screenshots", + }, + { + id: "appstore_iphone55", + name: 'iPhone 5.5"', + ratio: 1242 / 2208, + width: 1242, + height: 2208, + category: "App Store", + description: 'iPhone 5.5" screenshot', + useCase: "App Store Screenshots", + }, + { + id: "appstore_ipad", + name: 'iPad Pro 12.9"', + ratio: 2048 / 2732, + width: 2048, + height: 2732, + category: "App Store", + description: 'iPad Pro 12.9" screenshot', + useCase: "App Store Screenshots", + }, + { + id: "appstore_iphone65_landscape", + name: 'iPhone 6.5" Landscape', + ratio: 2778 / 1284, + width: 2778, + height: 1284, + category: "App Store", + description: 'iPhone 6.5" landscape screenshot', + useCase: "App Store Screenshots", + }, + { + id: "appstore_iphone55_landscape", + name: 'iPhone 5.5" Landscape', + ratio: 2208 / 1242, + width: 2208, + height: 1242, + category: "App Store", + description: 'iPhone 5.5" landscape screenshot', + useCase: "App Store Screenshots", + }, + { + id: "appstore_ipad_landscape", + name: 'iPad Pro 12.9" Landscape', + ratio: 2732 / 2048, + width: 2732, + height: 2048, + category: "App Store", + description: 'iPad Pro 12.9" landscape screenshot', + useCase: "App Store Screenshots", + }, + + // Custom - placeholder entry, actual dimensions come from store + { + id: "custom", + name: "Custom", + ratio: 16 / 9, + width: 16, + height: 9, + category: "Custom", + description: "Custom dimensions", + useCase: "Custom", + }, +]; + +export type AspectRatioKey = (typeof aspectRatios)[number]["id"]; diff --git a/apps/dashboard/lib/constants/backgrounds.ts b/apps/dashboard/lib/constants/backgrounds.ts new file mode 100644 index 0000000..ffe1b64 --- /dev/null +++ b/apps/dashboard/lib/constants/backgrounds.ts @@ -0,0 +1,144 @@ +import { getR2ImageUrl } from "@/lib/r2"; +import { backgroundPaths } from "@/lib/r2/r2-backgrounds"; +import { type GradientKey, gradientColors } from "./gradient-colors"; +import { + type MagicGradientKey, + type MeshGradientKey, + magicGradients, + meshGradients, +} from "./mesh-gradients"; +import { type SolidColorKey, solidColors } from "./solid-colors"; + +export type BackgroundType = "gradient" | "solid" | "image"; + +export interface BackgroundConfig { + opacity?: number; + type: BackgroundType; + value: GradientKey | SolidColorKey | string; +} + +export const getBackgroundStyle = (config: BackgroundConfig): string => { + const { type, value } = config; + + switch (type) { + case "gradient": { + if (typeof value === "string" && value.startsWith("mesh:")) { + const meshKey = value.replace("mesh:", "") as MeshGradientKey; + return meshGradients[meshKey] || gradientColors.vibrant_orange_pink; + } + if (typeof value === "string" && value.startsWith("magic:")) { + const magicKey = value.replace("magic:", "") as MagicGradientKey; + return magicGradients[magicKey] || gradientColors.vibrant_orange_pink; + } + return gradientColors[value as GradientKey]; + } + + case "solid": { + if (value === "transparent") { + return "transparent"; + } + if ( + typeof value === "string" && + (value.startsWith("#") || value.startsWith("rgb")) + ) { + return value; + } + const color = solidColors[value as SolidColorKey]; + return color || "#ffffff"; + } + + case "image": + return `url(${value})`; + + default: + return gradientColors.vibrant_orange_pink; + } +}; + +export const getBackgroundCSS = ( + config: BackgroundConfig +): React.CSSProperties => { + const { type, value, opacity = 1 } = config; + + switch (type) { + case "gradient": { + let gradient: string; + + if (typeof value === "string" && value.startsWith("mesh:")) { + const meshKey = value.replace("mesh:", "") as MeshGradientKey; + gradient = meshGradients[meshKey] || gradientColors.vibrant_orange_pink; + } else if (typeof value === "string" && value.startsWith("magic:")) { + const magicKey = value.replace("magic:", "") as MagicGradientKey; + gradient = + magicGradients[magicKey] || gradientColors.vibrant_orange_pink; + } else { + gradient = + gradientColors[value as GradientKey] || + gradientColors.vibrant_orange_pink; + } + + return { + background: gradient, + opacity, + }; + } + + case "solid": { + // Handle transparent background + if (value === "transparent") { + return { + backgroundColor: "transparent", + opacity: 1, + }; + } + // Handle direct color values (hex, rgb, rgba) + if ( + typeof value === "string" && + (value.startsWith("#") || value.startsWith("rgb")) + ) { + return { + backgroundColor: value, + opacity, + }; + } + const color = solidColors[value as SolidColorKey] || "#ffffff"; + return { + backgroundColor: color, + opacity, + }; + } + + case "image": { + // Local assets (from /public) are served directly + const isLocalPath = typeof value === "string" && value.startsWith("/"); + + // Check if it's a known R2 background path + const isR2Path = + typeof value === "string" && + !isLocalPath && + !value.startsWith("blob:") && + !value.startsWith("http") && + !value.startsWith("data:") && + backgroundPaths.includes(value); + + // Get the image URL (R2 URL if it's a known path, otherwise use as-is) + const imageUrl = isR2Path + ? getR2ImageUrl({ src: value }) + : (value as string); + + return { + backgroundImage: `url(${imageUrl})`, + backgroundSize: "cover", + backgroundPosition: "center", + backgroundRepeat: "no-repeat", + opacity, + }; + } + + default: + return { + background: gradientColors.vibrant_orange_pink, + opacity, + }; + } +}; diff --git a/apps/dashboard/lib/constants/fonts.ts b/apps/dashboard/lib/constants/fonts.ts new file mode 100644 index 0000000..7d41f50 --- /dev/null +++ b/apps/dashboard/lib/constants/fonts.ts @@ -0,0 +1,484 @@ +export interface FontFamily { + availableWeights: string[]; + category: + | "system" + | "sans-serif" + | "serif" + | "display" + | "handwriting" + | "monospace"; + cssVariable?: string; // CSS variable name for Next.js loaded fonts + description?: string; // Short description for UI + fallback: string; + id: string; + name: string; +} + +export const fontFamilies: FontFamily[] = [ + // ============ PREMIUM FONTS (Local) ============ + { + id: "sf-pro-display", + name: "SF Pro Display", + category: "sans-serif", + fallback: + '"SF Pro Display", -apple-system, BlinkMacSystemFont, system-ui, sans-serif', + availableWeights: [ + "100", + "200", + "300", + "normal", + "500", + "600", + "bold", + "800", + "900", + ], + description: "Apple's premium font", + }, + + // ============ SYSTEM FONTS ============ + { + id: "system", + name: "System Default", + category: "system", + fallback: + 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + availableWeights: ["normal", "bold"], + description: "Native system font", + }, + { + id: "arial", + name: "Arial", + category: "system", + fallback: "Arial, Helvetica, sans-serif", + availableWeights: ["normal", "bold"], + description: "Classic web-safe font", + }, + + // ============ MODERN SANS-SERIF ============ + { + id: "inter", + name: "Inter", + category: "sans-serif", + cssVariable: "--font-inter", + fallback: "Inter, system-ui, sans-serif", + availableWeights: [ + "100", + "200", + "300", + "normal", + "500", + "600", + "bold", + "800", + "900", + ], + description: "Modern UI favorite", + }, + { + id: "geist", + name: "Geist", + category: "sans-serif", + cssVariable: "--font-geist-sans", + fallback: "Geist, system-ui, sans-serif", + availableWeights: ["normal", "500", "600", "bold"], + description: "Vercel's modern typeface", + }, + { + id: "poppins", + name: "Poppins", + category: "sans-serif", + cssVariable: "--font-poppins", + fallback: "Poppins, sans-serif", + availableWeights: [ + "100", + "200", + "300", + "normal", + "500", + "600", + "bold", + "800", + "900", + ], + description: "Geometric & friendly", + }, + { + id: "space-grotesk", + name: "Space Grotesk", + category: "sans-serif", + cssVariable: "--font-space-grotesk", + fallback: "Space Grotesk, sans-serif", + availableWeights: ["300", "normal", "500", "600", "bold"], + description: "Tech & startup aesthetic", + }, + { + id: "outfit", + name: "Outfit", + category: "sans-serif", + cssVariable: "--font-outfit", + fallback: "Outfit, sans-serif", + availableWeights: [ + "100", + "200", + "300", + "normal", + "500", + "600", + "bold", + "800", + "900", + ], + description: "Modern & approachable", + }, + { + id: "plus-jakarta-sans", + name: "Plus Jakarta Sans", + category: "sans-serif", + cssVariable: "--font-plus-jakarta-sans", + fallback: "Plus Jakarta Sans, sans-serif", + availableWeights: ["200", "300", "normal", "500", "600", "bold", "800"], + description: "Clean & professional", + }, + { + id: "dm-sans", + name: "DM Sans", + category: "sans-serif", + cssVariable: "--font-dm-sans", + fallback: "DM Sans, sans-serif", + availableWeights: [ + "100", + "200", + "300", + "normal", + "500", + "600", + "bold", + "800", + "900", + ], + description: "Geometric & readable", + }, + { + id: "sora", + name: "Sora", + category: "sans-serif", + cssVariable: "--font-sora", + fallback: "Sora, sans-serif", + availableWeights: [ + "100", + "200", + "300", + "normal", + "500", + "600", + "bold", + "800", + ], + description: "Futuristic geometric", + }, + { + id: "manrope", + name: "Manrope", + category: "sans-serif", + cssVariable: "--font-manrope", + fallback: "Manrope, sans-serif", + availableWeights: ["200", "300", "normal", "500", "600", "bold", "800"], + description: "Modern & versatile", + }, + { + id: "raleway", + name: "Raleway", + category: "sans-serif", + cssVariable: "--font-raleway", + fallback: "Raleway, sans-serif", + availableWeights: [ + "100", + "200", + "300", + "normal", + "500", + "600", + "bold", + "800", + "900", + ], + description: "Elegant sans-serif", + }, + { + id: "montserrat", + name: "Montserrat", + category: "sans-serif", + cssVariable: "--font-montserrat", + fallback: "Montserrat, sans-serif", + availableWeights: [ + "100", + "200", + "300", + "normal", + "500", + "600", + "bold", + "800", + "900", + ], + description: "Urban & modern", + }, + { + id: "lexend", + name: "Lexend", + category: "sans-serif", + cssVariable: "--font-lexend", + fallback: "Lexend, sans-serif", + availableWeights: [ + "100", + "200", + "300", + "normal", + "500", + "600", + "bold", + "800", + "900", + ], + description: "Bold geometric, great readability", + }, + { + id: "work-sans", + name: "Work Sans", + category: "sans-serif", + cssVariable: "--font-work-sans", + fallback: "Work Sans, sans-serif", + availableWeights: [ + "100", + "200", + "300", + "normal", + "500", + "600", + "bold", + "800", + "900", + ], + description: "Clean & optimized for screens", + }, + { + id: "urbanist", + name: "Urbanist", + category: "sans-serif", + cssVariable: "--font-urbanist", + fallback: "Urbanist, sans-serif", + availableWeights: [ + "100", + "200", + "300", + "normal", + "500", + "600", + "bold", + "800", + "900", + ], + description: "Modern geometric sans", + }, + { + id: "albert-sans", + name: "Albert Sans", + category: "sans-serif", + cssVariable: "--font-albert-sans", + fallback: "Albert Sans, sans-serif", + availableWeights: [ + "100", + "200", + "300", + "normal", + "500", + "600", + "bold", + "800", + "900", + ], + description: "Geometric grotesk", + }, + + // ============ DISPLAY / CONDENSED ============ + { + id: "oswald", + name: "Oswald", + category: "display", + cssVariable: "--font-oswald", + fallback: "Oswald, Impact, sans-serif", + availableWeights: ["200", "300", "normal", "500", "600", "bold"], + description: "Bold condensed", + }, + { + id: "bebas-neue", + name: "Bebas Neue", + category: "display", + cssVariable: "--font-bebas-neue", + fallback: "Bebas Neue, Impact, sans-serif", + availableWeights: ["normal"], + description: "Impact headlines", + }, + { + id: "righteous", + name: "Righteous", + category: "display", + cssVariable: "--font-righteous", + fallback: "Righteous, cursive", + availableWeights: ["normal"], + description: "Retro-modern display", + }, + + // ============ SERIF ============ + { + id: "playfair-display", + name: "Playfair Display", + category: "serif", + cssVariable: "--font-playfair-display", + fallback: "Playfair Display, Georgia, serif", + availableWeights: ["normal", "500", "600", "bold", "800", "900"], + description: "Elegant display serif", + }, + { + id: "lora", + name: "Lora", + category: "serif", + cssVariable: "--font-lora", + fallback: "Lora, Georgia, serif", + availableWeights: ["normal", "500", "600", "bold"], + description: "Contemporary serif", + }, + { + id: "libre-baskerville", + name: "Libre Baskerville", + category: "serif", + cssVariable: "--font-libre-baskerville", + fallback: "Libre Baskerville, Georgia, serif", + availableWeights: ["normal", "bold"], + description: "Classic elegance", + }, + { + id: "georgia", + name: "Georgia", + category: "serif", + fallback: "Georgia, Times, serif", + availableWeights: ["normal", "bold"], + description: "Classic web serif", + }, + + // ============ HANDWRITING / SCRIPT ============ + { + id: "caveat", + name: "Caveat", + category: "handwriting", + cssVariable: "--font-caveat", + fallback: "Caveat, cursive", + availableWeights: ["normal", "500", "600", "bold"], + description: "Casual handwriting", + }, + { + id: "pacifico", + name: "Pacifico", + category: "handwriting", + cssVariable: "--font-pacifico", + fallback: "Pacifico, cursive", + availableWeights: ["normal"], + description: "Retro brush script", + }, + { + id: "dancing-script", + name: "Dancing Script", + category: "handwriting", + cssVariable: "--font-dancing-script", + fallback: "Dancing Script, cursive", + availableWeights: ["normal", "500", "600", "bold"], + description: "Elegant script", + }, + + // ============ MONOSPACE ============ + { + id: "geist-mono", + name: "Geist Mono", + category: "monospace", + cssVariable: "--font-geist-mono", + fallback: "Geist Mono, monospace", + availableWeights: ["normal", "500", "600", "bold"], + description: "Modern code font", + }, + { + id: "jetbrains-mono", + name: "JetBrains Mono", + category: "monospace", + cssVariable: "--font-jetbrains-mono", + fallback: "JetBrains Mono, monospace", + availableWeights: [ + "100", + "200", + "300", + "normal", + "500", + "600", + "bold", + "800", + ], + description: "Developer favorite", + }, + { + id: "fira-code", + name: "Fira Code", + category: "monospace", + cssVariable: "--font-fira-code", + fallback: "Fira Code, monospace", + availableWeights: ["300", "normal", "500", "600", "bold"], + description: "Code with ligatures", + }, + { + id: "courier", + name: "Courier New", + category: "monospace", + fallback: "Courier New, Courier, monospace", + availableWeights: ["normal", "bold"], + description: "Classic typewriter", + }, +]; + +// Group fonts by category for UI display +export const fontCategories = { + "sans-serif": "Modern Sans-Serif", + display: "Display & Headlines", + serif: "Elegant Serif", + handwriting: "Handwriting & Script", + monospace: "Monospace & Code", + system: "System Fonts", +} as const; + +export const getFontFamily = (id: string): FontFamily | undefined => + fontFamilies.find((font) => font.id === id); + +export const getFontCSS = (fontId: string): string => { + const font = getFontFamily(fontId); + if (!font) { + return fontFamilies[0].fallback; + } + + // If font has a CSS variable (Next.js loaded font), use it + if (font.cssVariable) { + return `var(${font.cssVariable}), ${font.fallback}`; + } + + return font.fallback; +}; + +export const getAvailableFontWeights = (fontId: string): string[] => { + const font = getFontFamily(fontId); + if (!font) { + return ["normal", "bold"]; + } + + return font.availableWeights; +}; + +export const getFontsByCategory = ( + category: FontFamily["category"] +): FontFamily[] => fontFamilies.filter((font) => font.category === category); diff --git a/apps/dashboard/lib/constants/gradient-colors.ts b/apps/dashboard/lib/constants/gradient-colors.ts new file mode 100644 index 0000000..a4f7f04 --- /dev/null +++ b/apps/dashboard/lib/constants/gradient-colors.ts @@ -0,0 +1,107 @@ +export const gradientColors = { + vibrant_orange_pink: 'linear-gradient(135deg, rgb(255, 100, 50) 12.8%, rgb(255, 0, 101) 43.52%, rgb(123, 46, 255) 84.34%)', + peach_pink_purple: 'linear-gradient(135deg, rgb(255, 177, 122) 12.8%, rgb(233, 107, 189) 43.52%, rgb(123, 79, 255) 84.34%)', + cyan_blue_purple: 'linear-gradient(135deg, rgb(0, 255, 229) 12.8%, rgb(75, 108, 255) 43.52%, rgb(156, 31, 217) 84.34%)', + orange_pink_dark: 'linear-gradient(135deg, rgb(255, 184, 107) 12.8%, rgb(255, 69, 133) 43.52%, rgb(47, 28, 150) 84.34%)', + green_teal_navy: 'linear-gradient(135deg, rgb(71, 246, 132) 12.8%, rgb(0, 184, 169) 43.52%, rgb(24, 78, 104) 84.34%)', + pink_red_yellow: 'linear-gradient(135deg, rgb(255, 97, 230) 12.8%, rgb(255, 51, 51) 43.52%, rgb(255, 184, 0) 84.34%)', + cyan_blue_violet: 'linear-gradient(135deg, rgb(0, 255, 224) 12.8%, rgb(0, 102, 255) 43.52%, rgb(102, 0, 255) 84.34%)', + peach_pink_lavender: 'linear-gradient(135deg, rgb(255, 177, 122) 12.8%, rgb(255, 77, 109) 43.52%, rgb(132, 94, 194) 84.34%)', + lime_cyan_blue: 'linear-gradient(135deg, rgb(89, 255, 0) 12.8%, rgb(0, 255, 209) 43.52%, rgb(0, 102, 255) 84.34%)', + pink_red_burgundy: 'linear-gradient(135deg, rgb(255, 94, 154) 12.8%, rgb(255, 0, 61) 43.52%, rgb(144, 0, 72) 84.34%)', + blue_pink: 'linear-gradient(135deg, rgb(52, 148, 230), rgb(236, 110, 173))', + green_blue: 'linear-gradient(135deg, rgb(103, 178, 111), rgb(76, 162, 205))', + pink_orange: 'linear-gradient(135deg, rgb(238, 9, 121), rgb(255, 106, 0))', + teal_navy: 'linear-gradient(135deg, rgb(67, 198, 172), rgb(25, 22, 84))', + pink_burgundy: 'linear-gradient(135deg, rgb(243, 98, 101), rgb(150, 18, 118))', + blue_lavender: 'linear-gradient(135deg, rgb(97, 144, 232), rgb(167, 191, 232))', + green_navy: 'linear-gradient(135deg, rgb(52, 232, 158), rgb(15, 52, 67))', + lime_mint: 'linear-gradient(135deg, rgb(170, 255, 169), rgb(17, 255, 189))', + cyan_blue: 'linear-gradient(135deg, rgb(178, 254, 250), rgb(14, 210, 247))', + mint_sky: 'linear-gradient(135deg, rgb(132, 250, 176), rgb(143, 211, 244))', + lavender_pink: 'linear-gradient(135deg, rgb(166, 192, 254), rgb(246, 128, 132))', + purple_sky: 'linear-gradient(135deg, rgb(224, 195, 252), rgb(142, 197, 252))', + cyan_lime: 'linear-gradient(135deg, rgb(0, 201, 255), rgb(146, 254, 157))', + navy_blue: 'linear-gradient(135deg, rgb(63, 43, 150), rgb(168, 192, 255))', + blue_teal: 'linear-gradient(135deg, rgb(0, 147, 233), rgb(128, 208, 199))', + mint_yellow: 'linear-gradient(135deg, rgb(133, 255, 189), rgb(255, 251, 125))', + sky_blue: 'linear-gradient(135deg, rgb(171, 220, 255), rgb(3, 150, 255))', + green_cyan: 'linear-gradient(135deg, rgb(81, 207, 102), rgb(55, 213, 214))', + pink_purple_blue: 'linear-gradient(135deg, rgb(255, 60, 172), rgb(120, 75, 160), rgb(43, 134, 197))', + pink_cyan_lime: 'linear-gradient(135deg, rgb(250, 139, 255), rgb(43, 210, 255), rgb(43, 255, 136))', + blue_purple: 'linear-gradient(135deg, rgb(139, 198, 236), rgb(149, 153, 226))', + mint_pink: 'linear-gradient(135deg, rgb(62, 236, 172), rgb(238, 116, 225))', + blue_pink_dark: 'linear-gradient(135deg, rgb(2, 80, 197), rgb(212, 63, 141))', + pink_blue: 'linear-gradient(135deg, rgb(252, 70, 107), rgb(63, 94, 251))', + purple_pink_white: 'linear-gradient(135deg, rgb(115, 3, 192), rgb(236, 56, 188), rgb(253, 239, 249))', + blue_pink_yellow: 'linear-gradient(135deg, rgb(65, 88, 208), rgb(200, 80, 192), rgb(255, 204, 112))', + cyan_magenta: 'linear-gradient(135deg, rgb(0, 219, 222), rgb(252, 0, 255))', + peach_coral: 'linear-gradient(135deg, rgb(255, 154, 158), rgb(250, 208, 196))', + peach_purple: 'linear-gradient(135deg, rgb(246, 211, 101), rgb(253, 160, 133))', + pink_orange_light: 'linear-gradient(135deg, rgb(252, 203, 144), rgb(213, 126, 235))', + yellow_cyan: 'linear-gradient(135deg, rgb(255, 95, 109), rgb(255, 195, 113))', + pink_yellow: 'linear-gradient(135deg, rgb(253, 187, 45), rgb(34, 193, 195))', + pink_light: 'linear-gradient(135deg, rgb(212, 20, 90), rgb(251, 176, 59))', + peach_pink: 'linear-gradient(135deg, rgb(254, 225, 64), rgb(250, 112, 154))', + yellow_blue: 'linear-gradient(135deg, rgb(255, 117, 140), rgb(255, 126, 179))', + pink_light_2: 'linear-gradient(135deg, rgb(240, 147, 251), rgb(245, 87, 108))', + yellow_pink: 'linear-gradient(135deg, rgb(255, 236, 210), rgb(252, 182, 159))', + pink_light_3: 'linear-gradient(135deg, rgb(161, 140, 209), rgb(251, 194, 235))', + peach_light: 'linear-gradient(135deg, rgb(169, 201, 255), rgb(255, 187, 236))', + yellow_blue_2: 'linear-gradient(135deg, rgb(101, 253, 240), rgb(29, 111, 163))', + rainbow: 'linear-gradient(135deg, rgb(255, 154, 139), rgb(255, 106, 136), rgb(255, 153, 172))', + gray_red: 'linear-gradient(135deg, rgb(251, 218, 97), rgb(255, 90, 205))', + navy_purple: 'linear-gradient(135deg, rgb(255, 184, 184), rgb(255, 184, 209), rgb(255, 184, 233))', + purple_orange: 'linear-gradient(135deg, rgb(250, 215, 161), rgb(233, 109, 113))', + purple_green: 'linear-gradient(135deg, rgb(255, 210, 111), rgb(54, 119, 255))', + blue_cyan: 'linear-gradient(135deg, rgb(5, 25, 55), rgb(0, 77, 122), rgb(0, 135, 147), rgb(0, 191, 114), rgb(168, 235, 18))', + purple_yellow: 'linear-gradient(135deg, rgb(51, 51, 51), rgb(221, 24, 24))', + navy_cyan: 'linear-gradient(135deg, rgb(15, 12, 41), rgb(48, 43, 99), rgb(36, 36, 62))', + slate_blue: 'linear-gradient(135deg, rgb(35, 7, 77), rgb(204, 83, 51))', + slate_lavender: 'linear-gradient(135deg, rgb(93, 65, 87), rgb(168, 202, 186))', + teal_green: 'linear-gradient(135deg, rgb(26, 41, 128), rgb(38, 208, 206))', + blue_orange: 'linear-gradient(135deg, rgb(75, 18, 72), rgb(240, 194, 123))', + pink_blue_2: 'linear-gradient(135deg, rgb(0, 0, 70), rgb(28, 181, 224))', + purple_pink: 'linear-gradient(135deg, rgb(22, 34, 42), rgb(58, 96, 115))', + purple_yellow_2: 'linear-gradient(135deg, rgb(31, 28, 44), rgb(146, 141, 171))', + navy_cyan_2: 'linear-gradient(135deg, rgb(17, 153, 142), rgb(56, 239, 125))', + purple_blue: 'linear-gradient(135deg, rgb(16, 141, 199), rgb(239, 142, 56))', + blue_yellow: 'linear-gradient(135deg, rgb(252, 92, 125), rgb(106, 130, 251))', + green_lime: 'linear-gradient(135deg, rgb(131, 77, 155), rgb(208, 78, 214))', + blue_green: 'linear-gradient(135deg, rgb(77, 160, 176), rgb(211, 157, 56))', + red_yellow: 'linear-gradient(135deg, rgb(86, 20, 176), rgb(219, 214, 92))', + cyan_white: 'linear-gradient(135deg, rgb(29, 151, 108), rgb(147, 249, 185))', + yellow_cyan_2: 'linear-gradient(135deg, rgb(33, 147, 176), rgb(109, 213, 237))', + lime_yellow: 'linear-gradient(135deg, rgb(204, 43, 94), rgb(117, 58, 136))', + purple_peach: 'linear-gradient(135deg, rgb(0, 70, 127), rgb(165, 204, 130))', + purple_cream: 'linear-gradient(135deg, rgb(248, 54, 0), rgb(249, 212, 35))', + peach_pink_2: 'linear-gradient(135deg, rgb(0, 255, 161), rgb(0, 255, 255))', + mint_pink_2: 'linear-gradient(135deg, rgb(240, 255, 0), rgb(88, 207, 251))', + beige_purple: 'linear-gradient(135deg, rgb(255, 249, 91), rgb(255, 147, 15))', + yellow_green: 'linear-gradient(135deg, rgb(252, 82, 150), rgb(246, 112, 98))', + pink_dark: 'linear-gradient(135deg, rgb(123, 255, 0), rgb(60, 213, 0))', + blue_yellow_2: 'linear-gradient(135deg, rgb(255, 0, 204), rgb(51, 51, 153))', + purple_magenta: 'linear-gradient(135deg, rgb(255, 19, 97), rgb(255, 248, 0))', + orange_red: 'linear-gradient(135deg, rgb(64, 224, 208), rgb(255, 140, 0), rgb(255, 0, 128))', + blue_cyan_lime: 'linear-gradient(135deg, rgb(138, 35, 135), rgb(233, 64, 87), rgb(242, 113, 33))', + blue_purple_2: 'linear-gradient(135deg, rgb(255, 239, 186), rgb(255, 255, 255))', + red_orange: 'linear-gradient(135deg, rgb(161, 255, 206), rgb(250, 255, 209))', + gradient_88: 'linear-gradient(135deg, rgb(243, 249, 167), rgb(202, 197, 49))', + gradient_89: 'linear-gradient(135deg, rgb(221, 214, 243), rgb(250, 172, 168))', + gradient_90: 'linear-gradient(135deg, rgb(232, 219, 252), rgb(248, 249, 210))', + gradient_91: 'linear-gradient(135deg, rgb(238, 205, 163), rgb(239, 98, 159))', + gradient_92: 'linear-gradient(135deg, rgb(201, 255, 191), rgb(255, 175, 189))', + gradient_93: 'linear-gradient(135deg, rgb(232, 203, 192), rgb(99, 111, 164))', + gradient_94: 'linear-gradient(135deg, rgb(220, 227, 91), rgb(69, 182, 73))', + gradient_95: 'linear-gradient(135deg, rgb(255, 0, 153), rgb(73, 50, 64))', + gradient_96: 'linear-gradient(135deg, rgb(0, 79, 249), rgb(255, 249, 76))', + gradient_97: 'linear-gradient(135deg, rgb(127, 0, 255), rgb(225, 0, 255))', + gradient_98: 'linear-gradient(135deg, rgb(253, 200, 48), rgb(243, 115, 53))', + gradient_99: 'linear-gradient(135deg, rgb(237, 33, 58), rgb(147, 41, 30))', + gradient_100: 'linear-gradient(135deg, rgb(31, 162, 255), rgb(18, 216, 250), rgb(166, 255, 203))', + gradient_101: 'linear-gradient(135deg, rgb(69, 104, 220), rgb(176, 106, 179))', + gradient_102: 'linear-gradient(135deg, rgb(255, 81, 47), rgb(221, 36, 118))', +}; + +export type GradientKey = keyof typeof gradientColors; + diff --git a/apps/dashboard/lib/constants/index.ts b/apps/dashboard/lib/constants/index.ts new file mode 100644 index 0000000..261230c --- /dev/null +++ b/apps/dashboard/lib/constants/index.ts @@ -0,0 +1,7 @@ +/** biome-ignore-all lint/performance/noBarrelFile: + MOCKUP_DEFINITIONS.find((def) => def.id === id); + +export const getMockupsByType = ( + type: "iphone" | "macbook" | "imac" | "iwatch" +): MockupDefinition[] => MOCKUP_DEFINITIONS.filter((def) => def.type === type); diff --git a/apps/dashboard/lib/constants/overlays.ts b/apps/dashboard/lib/constants/overlays.ts new file mode 100644 index 0000000..0ecb274 --- /dev/null +++ b/apps/dashboard/lib/constants/overlays.ts @@ -0,0 +1,7 @@ +/** + * Available anime character overlay images + * Using R2 paths for delivery + */ +import { OVERLAY_PATHS } from '@/lib/r2-overlays' + +export const OVERLAY_IMAGES = OVERLAY_PATHS as readonly string[] diff --git a/apps/dashboard/lib/constants/presets.ts b/apps/dashboard/lib/constants/presets.ts new file mode 100644 index 0000000..38d6804 --- /dev/null +++ b/apps/dashboard/lib/constants/presets.ts @@ -0,0 +1,339 @@ +import type { ImageBorder, ImageShadow } from "@/lib/store"; +import type { AspectRatioKey } from "./aspect-ratios"; +import type { BackgroundConfig } from "./backgrounds"; + +export interface PresetConfig { + aspectRatio: AspectRatioKey; + backgroundBlur?: number; + backgroundBorderRadius: number; + backgroundConfig: BackgroundConfig; + backgroundNoise?: number; + borderRadius: number; + description: string; + id: string; + imageBorder: ImageBorder; + imageOpacity: number; + imageScale: number; + imageShadow: ImageShadow; + name: string; + perspective3D?: { + perspective: number; + rotateX: number; + rotateY: number; + rotateZ: number; + translateX: number; + translateY: number; + scale: number; + }; + shadowOverlay?: { + src: string; + opacity: number; + }; +} + +export const presets: PresetConfig[] = [ + // 1. Spotlight - Dramatic dark with focused light + { + id: "spotlight", + name: "Spotlight", + description: "Dramatic dark with focused attention", + aspectRatio: "16_9", + backgroundConfig: { + type: "image", + value: "backgrounds/raycast/mono_dark_distortion_2.webp", + opacity: 1, + }, + borderRadius: 8, + backgroundBorderRadius: 0, + imageOpacity: 1, + imageScale: 100, + imageBorder: { + enabled: true, + width: 8, + color: "#1a1a1a", + type: "arc-dark", + }, + imageShadow: { + enabled: true, + blur: 40, + offsetX: 10, + offsetY: 10, + spread: 20, + color: "rgba(255, 255, 255, 0.08)", + opacity: 0.5, + }, + backgroundBlur: 4, + backgroundNoise: 0, + }, + + // 8. Magazine Flatlay - Lying on textured surface with leaf shadows + { + id: "magazine-flatlay", + name: "Magazine Flatlay", + description: "Isometric flatlay with leaf shadows", + aspectRatio: "16_9", + backgroundConfig: { + type: "image", + value: "backgrounds/paper/26.webp", + opacity: 1, + }, + borderRadius: 16, + backgroundBorderRadius: 0, + imageOpacity: 1, + imageScale: 100, + imageBorder: { + enabled: true, + width: 3, + color: "#ffffff", + type: "photograph", + }, + imageShadow: { + enabled: true, + blur: 20, + offsetX: 10, + offsetY: 15, + spread: 0, + color: "rgba(0, 0, 0, 0.4)", + opacity: 0.5, + }, + backgroundBlur: 0, + backgroundNoise: 15, + perspective3D: { + perspective: 2400, + rotateX: 45, + rotateY: 0, + rotateZ: -45, + translateX: 0, + translateY: -5, + scale: 0.9, + }, + shadowOverlay: { + src: "/overlay-shadow/041.webp", + opacity: 0.2, + }, + }, + + // 2. Lifted - Floating with hard shadow + { + id: "lifted", + name: "Lifted", + description: "Bold floating effect with hard shadow", + aspectRatio: "1_1", + backgroundConfig: { + type: "image", + value: "backgrounds/raycast/loupe-mono-light.webp", + opacity: 1, + }, + borderRadius: 16, + backgroundBorderRadius: 0, + imageOpacity: 1, + imageScale: 75, + imageBorder: { + enabled: false, + width: 0, + color: "#ffffff", + type: "none", + }, + imageShadow: { + enabled: true, + blur: 2, + offsetX: 20, + offsetY: 20, + spread: 0, + color: "rgba(0, 0, 0, 0)", + opacity: 0, + }, + backgroundBlur: 0, + backgroundNoise: 0, + shadowOverlay: { + src: "/overlay-shadow/017.webp", + opacity: 0.5, + }, + }, + + // 3. Neon Dreams - Vibrant with colored glow + { + id: "neon-dreams", + name: "Neon Dreams", + description: "Vibrant glow for creative content", + aspectRatio: "16_9", + backgroundConfig: { + type: "image", + value: "backgrounds/raycast/chromatic_dark_2.webp", + opacity: 1, + }, + borderRadius: 16, + backgroundBorderRadius: 20, + imageOpacity: 1, + imageScale: 100, + imageBorder: { + enabled: true, + width: 8, + color: "rgba(255,255,255,0.1)", + type: "arc-dark", + }, + imageShadow: { + enabled: true, + blur: 40, + offsetX: 0, + offsetY: 20, + spread: 0, + color: "#401d90", + opacity: 0.5, + }, + backgroundBlur: 0, + backgroundNoise: 0, + }, + + // 4. Editorial - Clean magazine style + { + id: "editorial", + name: "Editorial", + description: "Clean magazine-style presentation", + aspectRatio: "4_5", + backgroundConfig: { + type: "image", + value: "backgrounds/paper/27.webp", + opacity: 1, + }, + borderRadius: 0, + backgroundBorderRadius: 0, + imageOpacity: 1, + imageScale: 88, + imageBorder: { + enabled: true, + width: 8, + color: "#ffffff", + type: "none", + }, + imageShadow: { + enabled: false, + blur: 0, + offsetX: 0, + offsetY: 0, + spread: 0, + color: "rgba(0, 0, 0, 0)", + opacity: 0, + }, + backgroundBlur: 0, + backgroundNoise: 25, + shadowOverlay: { + src: "/overlay-shadow/019.webp", + opacity: 0.5, + }, + }, + + // 9. Desktop View - Tilted on magic gradient + { + id: "desktop-view", + name: "Desktop View", + description: "Tilted view with magic gradient", + aspectRatio: "16_9", + backgroundConfig: { + type: "gradient", + value: "magic:magic_teal_dots", + opacity: 1, + }, + borderRadius: 8, + backgroundBorderRadius: 0, + imageOpacity: 1, + imageScale: 100, + imageBorder: { + enabled: true, + width: 8, + color: "#2a2a2a", + type: "arc-dark", + }, + imageShadow: { + enabled: true, + blur: 20, + offsetX: -15, + offsetY: 25, + spread: -10, + color: "rgba(0, 0, 0, 0.5)", + opacity: 0.5, + }, + backgroundBlur: 0, + backgroundNoise: 0, + perspective3D: { + perspective: 2400, + rotateX: 0, + rotateY: 0, + rotateZ: -8, + translateX: 0, + translateY: 0, + scale: 0.95, + }, + }, + + // 5. Glass Card - Modern glassmorphism + { + id: "glass-card", + name: "Glass Card", + description: "Modern frosted glass effect", + aspectRatio: "16_9", + backgroundConfig: { + type: "image", + value: "backgrounds/mesh/Peak.webp", + opacity: 1, + }, + borderRadius: 24, + backgroundBorderRadius: 32, + imageOpacity: 1, + imageScale: 100, + imageBorder: { + enabled: true, + width: 8, + color: "rgba(255,255,255,0.2)", + type: "arc-dark", + }, + imageShadow: { + enabled: true, + blur: 40, + offsetX: 0, + offsetY: 30, + spread: -15, + color: "rgba(0, 0, 0, 0.3)", + opacity: 0.5, + }, + backgroundBlur: 0, + backgroundNoise: 30, + }, + + // 7. Sunset Fade - Warm gradient vibes + { + id: "sunset-fade", + name: "Sunset Fade", + description: "Warm tones for lifestyle content", + aspectRatio: "og_image", + backgroundConfig: { + type: "image", + value: "backgrounds/raycast/blushing-fire.webp", + opacity: 1, + }, + borderRadius: 20, + backgroundBorderRadius: 24, + imageOpacity: 1, + imageScale: 100, + imageBorder: { + enabled: true, + width: 8, + color: "#ffffff", + type: "arc-light", + }, + imageShadow: { + enabled: true, + blur: 50, + offsetX: 0, + offsetY: 25, + spread: -10, + color: "rgba(0, 0, 0, 0.25)", + opacity: 0.5, + }, + backgroundBlur: 0, + backgroundNoise: 0, + }, +]; + +export const getPresetById = (id: string): PresetConfig | undefined => + presets.find((preset) => preset.id === id); diff --git a/apps/dashboard/lib/constants/solid-colors.ts b/apps/dashboard/lib/constants/solid-colors.ts new file mode 100644 index 0000000..baf2631 --- /dev/null +++ b/apps/dashboard/lib/constants/solid-colors.ts @@ -0,0 +1,51 @@ +export const solidColors = { + // Row 1 + white: '#ffffff', + very_light_gray: '#e5e5e5', + medium_light_gray: '#b3b3b3', + dark_gray: '#333333', + + // Row 2 + dark_charcoal: '#4a4a4a', + darker_charcoal: '#2a2a2a', + black: '#000000', + coral_red: '#ff6b6b', + bright_lime: '#32cd32', + + // Row 3 + orange: '#ffa500', + bright_yellow: '#ffff00', + light_olive_green: '#b8d433', + medium_green: '#4caf50', + light_pastel_pink: '#ffb3d9', + + // Row 4 + medium_green_2: '#66bb6a', + light_pastel_pink_2: '#ffc0e1', + light_peach: '#ffd9b3', + light_beige: '#fff5e6', + light_teal: '#80d4c7', + + // Row 5 + light_yellow: '#fffacd', + light_mint_green: '#c8f7c8', + light_teal_2: '#7fcdcd', + medium_blue: '#4a90e2', + medium_purple_blue: '#8b7ec8', + + // Row 6 + medium_blue_2: '#5dade2', + medium_purple_blue_2: '#9b7ec8', + darker_purple: '#6a5acd', + bright_fuchsia: '#ff00ff', + light_mint_green_2: '#b3ffb3', + + // Row 7 + light_pastel_blue: '#b3d9ff', + light_lavender: '#d4b3ff', + light_pastel_pink_3: '#ffb3d9', + light_pastel_pink_4: '#ffc0e1', +} as const; + +export type SolidColorKey = keyof typeof solidColors; + diff --git a/apps/dashboard/lib/export/export-utils.ts b/apps/dashboard/lib/export/export-utils.ts new file mode 100644 index 0000000..df503d5 --- /dev/null +++ b/apps/dashboard/lib/export/export-utils.ts @@ -0,0 +1,462 @@ +/** + * Export utility functions for style conversion and canvas configuration + */ + +import { exportWorkerService } from "@/lib/workers/export-worker-service"; + +/** + * Convert CSS variables and computed styles to RGB + */ +export function convertStylesToRGB(element: HTMLElement, doc: Document): void { + const win = doc.defaultView || (doc as any).parentWindow; + if (!win) { + return; + } + + try { + const computedStyle = win.getComputedStyle(element); + const allProps = [ + "color", + "backgroundColor", + "borderColor", + "borderTopColor", + "borderRightColor", + "borderBottomColor", + "borderLeftColor", + "outlineColor", + "boxShadow", + "textShadow", + "background", + "backgroundImage", + "backgroundColor", + "fill", + "stroke", + ]; + + // Convert all relevant CSS properties + for (const prop of allProps) { + try { + const value = computedStyle.getPropertyValue(prop); + if (value && (value.includes("oklch") || value.includes("var("))) { + const computed = (computedStyle as any)[prop]; + if ( + computed && + computed !== "rgba(0, 0, 0, 0)" && + computed !== "transparent" && + computed !== "none" && + !computed.includes("oklch") + ) { + element.style.setProperty(prop, computed, "important"); + } + } + } catch (e) { + // Ignore errors for individual properties + } + } + + // Also check inline styles + if (element.style?.cssText) { + const cssText = element.style.cssText; + if (cssText.includes("oklch") || cssText.includes("var(")) { + // Re-apply computed styles + for (const prop of allProps) { + try { + const computed = (computedStyle as any)[prop]; + if (computed && !computed.includes("oklch")) { + element.style.setProperty(prop, computed, "important"); + } + } catch (e) { + // Ignore errors + } + } + } + } + } catch (e) { + // Ignore errors + } + + // Convert all children recursively + for (const child of Array.from(element.children)) { + if (child instanceof HTMLElement) { + convertStylesToRGB(child, doc); + } + } +} + +/** + * Inject CSS overrides to replace oklch variables + */ +export function injectRGBOverrides(doc: Document): void { + // Remove or disable stylesheets that might contain oklch + const stylesheets = Array.from(doc.styleSheets); + for (const sheet of stylesheets) { + try { + if (sheet.href?.includes("globals.css")) { + try { + (sheet as any).disabled = true; + } catch (e) { + // Ignore + } + } + } catch (e) { + // Ignore cross-origin errors + } + } + + // Inject CSS overrides with high specificity + const style = doc.createElement("style"); + style.id = "oklch-rgb-converter"; + style.textContent = ` + :root, :root * { + --background: rgb(255, 255, 255) !important; + --foreground: rgb(33, 33, 33) !important; + --card: rgb(255, 255, 255) !important; + --card-foreground: rgb(33, 33, 33) !important; + --popover: rgb(255, 255, 255) !important; + --popover-foreground: rgb(33, 33, 33) !important; + --primary: rgb(37, 37, 37) !important; + --primary-foreground: rgb(251, 251, 251) !important; + --secondary: rgb(247, 247, 247) !important; + --secondary-foreground: rgb(37, 37, 37) !important; + --muted: rgb(247, 247, 247) !important; + --muted-foreground: rgb(140, 140, 140) !important; + --accent: rgb(247, 247, 247) !important; + --accent-foreground: rgb(37, 37, 37) !important; + --destructive: rgb(239, 68, 68) !important; + --border: rgb(237, 237, 237) !important; + --input: rgb(237, 237, 237) !important; + --ring: rgb(180, 180, 180) !important; + --sidebar: rgb(251, 251, 251) !important; + --sidebar-foreground: rgb(33, 33, 33) !important; + --sidebar-primary: rgb(37, 37, 37) !important; + --sidebar-primary-foreground: rgb(251, 251, 251) !important; + --sidebar-accent: rgb(247, 247, 247) !important; + --sidebar-accent-foreground: rgb(37, 37, 37) !important; + --sidebar-border: rgb(237, 237, 237) !important; + --sidebar-ring: rgb(180, 180, 180) !important; + } + *:not(img) { + border-color: rgb(237, 237, 237) !important; + outline-color: rgba(180, 180, 180, 0.5) !important; + } + `; + + const head = + doc.head || doc.getElementsByTagName("head")[0] || doc.documentElement; + if (head) { + head.insertBefore(style, head.firstChild); + } +} + +/** + * Preserve image styles from original document + */ +export function preserveImageStyles( + img: HTMLImageElement, + clonedDoc: Document +): void { + // Force display for images that might be hidden + if (img.style.display === "none") { + img.style.display = ""; + } + + // Preserve border styles - get from original document and apply with !important + const originalImg = document.querySelector( + `img[src="${img.getAttribute("src")}"]` + ); + if (originalImg && originalImg instanceof HTMLElement) { + const originalStyle = window.getComputedStyle(originalImg); + + // Get border properties + const borderTop = originalStyle.borderTop; + const borderRight = originalStyle.borderRight; + const borderBottom = originalStyle.borderBottom; + const borderLeft = originalStyle.borderLeft; + const borderRadius = originalStyle.borderRadius; + const boxShadow = originalStyle.boxShadow; + const opacity = originalStyle.opacity; + const transform = originalStyle.transform; + + // Get border colors separately + const borderTopColor = originalStyle.borderTopColor; + const borderRightColor = originalStyle.borderRightColor; + const borderBottomColor = originalStyle.borderBottomColor; + const borderLeftColor = originalStyle.borderLeftColor; + + if ( + borderTop && + borderTop !== "0px none rgb(0, 0, 0)" && + borderTop !== "0px none" + ) { + const borderTopWidth = originalStyle.borderTopWidth; + const borderTopStyle = originalStyle.borderTopStyle; + if (borderTopWidth !== "0px" && borderTopStyle !== "none") { + const borderValue = `${borderTopWidth} ${borderTopStyle} ${borderTopColor}`; + img.style.setProperty("border-top", borderValue, "important"); + } + } + if ( + borderRight && + borderRight !== "0px none rgb(0, 0, 0)" && + borderRight !== "0px none" + ) { + const borderRightWidth = originalStyle.borderRightWidth; + const borderRightStyle = originalStyle.borderRightStyle; + if (borderRightWidth !== "0px" && borderRightStyle !== "none") { + const borderValue = `${borderRightWidth} ${borderRightStyle} ${borderRightColor}`; + img.style.setProperty("border-right", borderValue, "important"); + } + } + if ( + borderBottom && + borderBottom !== "0px none rgb(0, 0, 0)" && + borderBottom !== "0px none" + ) { + const borderBottomWidth = originalStyle.borderBottomWidth; + const borderBottomStyle = originalStyle.borderBottomStyle; + if (borderBottomWidth !== "0px" && borderBottomStyle !== "none") { + const borderValue = `${borderBottomWidth} ${borderBottomStyle} ${borderBottomColor}`; + img.style.setProperty("border-bottom", borderValue, "important"); + } + } + if ( + borderLeft && + borderLeft !== "0px none rgb(0, 0, 0)" && + borderLeft !== "0px none" + ) { + const borderLeftWidth = originalStyle.borderLeftWidth; + const borderLeftStyle = originalStyle.borderLeftStyle; + if (borderLeftWidth !== "0px" && borderLeftStyle !== "none") { + const borderValue = `${borderLeftWidth} ${borderLeftStyle} ${borderLeftColor}`; + img.style.setProperty("border-left", borderValue, "important"); + } + } + if (borderRadius && borderRadius !== "0px") { + img.style.setProperty("border-radius", borderRadius, "important"); + } + if (boxShadow && boxShadow !== "none") { + img.style.setProperty("box-shadow", boxShadow, "important"); + } + if (opacity && opacity !== "1") { + img.style.setProperty("opacity", opacity, "important"); + } + if (transform && transform !== "none") { + img.style.setProperty("transform", transform, "important"); + } + } +} + +/** + * Convert SVG elements fill/stroke attributes + */ +export function convertSVGStyles( + targetElement: HTMLElement, + clonedDoc: Document +): void { + const svgElements = targetElement.querySelectorAll("svg, [fill], [stroke]"); + for (const svg of svgElements) { + if (svg instanceof HTMLElement || svg instanceof SVGElement) { + const fill = svg.getAttribute("fill"); + const stroke = svg.getAttribute("stroke"); + + if (fill && (fill.includes("oklch") || fill.includes("var("))) { + const temp = clonedDoc.createElement("div"); + temp.style.color = fill; + const computed = clonedDoc.defaultView?.getComputedStyle(temp).color; + if (computed && !computed.includes("oklch")) { + svg.setAttribute("fill", computed); + } + temp.remove(); + } + + if (stroke && (stroke.includes("oklch") || stroke.includes("var("))) { + const temp = clonedDoc.createElement("div"); + temp.style.color = stroke; + const computed = clonedDoc.defaultView?.getComputedStyle(temp).color; + if (computed && !computed.includes("oklch")) { + svg.setAttribute("stroke", computed); + } + temp.remove(); + } + } + } +} + +/** + * Setup element for export with proper dimensions + */ +export function setupExportElement( + targetElement: HTMLElement, + exportWidth: number, + exportHeight: number, + clonedDoc: Document +): void { + targetElement.style.width = `${exportWidth}px`; + targetElement.style.height = `${exportHeight}px`; + targetElement.style.maxWidth = "none"; + targetElement.style.maxHeight = "none"; + + // Also ensure parent containers don't constrain the size + let parent = targetElement.parentElement; + while (parent && parent !== clonedDoc.body) { + if (parent.style) { + parent.style.width = "auto"; + parent.style.height = "auto"; + parent.style.maxWidth = "none"; + parent.style.maxHeight = "none"; + parent.style.display = "flex"; + parent.style.alignItems = "center"; + parent.style.justifyContent = "center"; + } + parent = parent.parentElement; + } +} + +/** + * Wait for all images to load + */ +export async function waitForImages(element: HTMLElement): Promise { + const images = element.getElementsByTagName("img"); + const imagePromises = Array.from(images).map((img) => { + if (img.complete) { + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = () => reject(new Error("Image failed to load")); + setTimeout(() => reject(new Error("Image load timeout")), 5000); + }); + }); + + try { + await Promise.all(imagePromises); + } catch (error) { + console.warn("Some images failed to load, continuing with export:", error); + } +} + +/** + * Generate Gaussian (normal) distributed random number using Box-Muller transform + * This creates more natural-looking noise compared to uniform random + */ +function gaussianRandom(mean = 0, stdDev = 1): number { + let u = 0, + v = 0; + while (u === 0) { + u = Math.random(); // Converting [0,1) to (0,1) + } + while (v === 0) { + v = Math.random(); + } + const z = Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v); + return z * stdDev + mean; +} + +/** + * Generate a noise texture canvas with Gaussian-distributed noise (sync version for fallback) + * This creates realistic image grain/noise similar to film grain or sensor noise + * + * @param width - Canvas width in pixels + * @param height - Canvas height in pixels + * @param intensity - Noise intensity (0-1), controls the standard deviation + * @returns Canvas element with noise texture + */ +function generateNoiseTextureSync( + width: number, + height: number, + intensity: number +): HTMLCanvasElement { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + + if (!ctx) { + return canvas; + } + + const imageData = ctx.createImageData(width, height); + const data = imageData.data; + + // Intensity controls the standard deviation of the Gaussian distribution + // Higher intensity = more variation in pixel values + const stdDev = intensity * 50; // Scale to reasonable range (0-50) + + // Generate Gaussian noise for each pixel + for (let i = 0; i < data.length; i += 4) { + // Generate Gaussian noise value centered at 128 (mid-gray) + const noise = gaussianRandom(128, stdDev); + + // Clamp to valid RGB range [0, 255] + const value = Math.max(0, Math.min(255, Math.round(noise))); + + // Apply same value to R, G, B for grayscale noise + data[i] = value; // R + data[i + 1] = value; // G + data[i + 2] = value; // B + data[i + 3] = 255; // A (fully opaque) + } + + ctx.putImageData(imageData, 0, 0); + return canvas; +} + +/** + * Generate a noise texture canvas with Gaussian-distributed noise + * Uses Web Worker for heavy computation to prevent UI blocking + * Falls back to synchronous generation if worker is unavailable + * + * @param width - Canvas width in pixels + * @param height - Canvas height in pixels + * @param intensity - Noise intensity (0-1), controls the standard deviation + * @returns Canvas element with noise texture + */ +export function generateNoiseTexture( + width: number, + height: number, + intensity: number +): HTMLCanvasElement { + // Return sync version for immediate use + // The async version is available via generateNoiseTextureAsync + return generateNoiseTextureSync(width, height, intensity); +} + +/** + * Generate a noise texture canvas asynchronously using Web Worker + * This offloads heavy computation to a worker thread to prevent UI blocking + * + * @param width - Canvas width in pixels + * @param height - Canvas height in pixels + * @param intensity - Noise intensity (0-1), controls the standard deviation + * @returns Promise resolving to Canvas element with noise texture + */ +export async function generateNoiseTextureAsync( + width: number, + height: number, + intensity: number +): Promise { + try { + // Use worker service for heavy computation + const imageData = await exportWorkerService.generateNoiseTexture( + width, + height, + intensity + ); + + // Convert ImageData to canvas + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + + if (!ctx) { + return generateNoiseTextureSync(width, height, intensity); + } + + ctx.putImageData(imageData, 0, 0); + return canvas; + } catch (error) { + console.warn("Async noise generation failed, using sync fallback:", error); + return generateNoiseTextureSync(width, height, intensity); + } +} diff --git a/apps/dashboard/lib/patterns.ts b/apps/dashboard/lib/patterns.ts new file mode 100644 index 0000000..cc051e7 --- /dev/null +++ b/apps/dashboard/lib/patterns.ts @@ -0,0 +1,82 @@ +export function generatePattern( + type: string, + scale = 1, + spacing = 20, + color = "#000000", + rotation = 0, + blur = 0 +): HTMLCanvasElement { + const canvas = document.createElement("canvas"); + const size = 100 * scale; + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext("2d"); + + if (!ctx) { + return canvas; + } + + ctx.save(); + ctx.translate(size / 2, size / 2); + ctx.rotate((rotation * Math.PI) / 180); + ctx.translate(-size / 2, -size / 2); + + ctx.strokeStyle = color; + ctx.fillStyle = color; + ctx.lineWidth = 1; + + switch (type) { + case "grid": + for (let i = 0; i <= size; i += spacing) { + ctx.beginPath(); + ctx.moveTo(i, 0); + ctx.lineTo(i, size); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(0, i); + ctx.lineTo(size, i); + ctx.stroke(); + } + break; + case "dots": + for (let x = spacing / 2; x < size; x += spacing) { + for (let y = spacing / 2; y < size; y += spacing) { + ctx.beginPath(); + ctx.arc(x, y, 2, 0, Math.PI * 2); + ctx.fill(); + } + } + break; + case "lines": + for (let i = 0; i <= size; i += spacing) { + ctx.beginPath(); + ctx.moveTo(i, 0); + ctx.lineTo(i, size); + ctx.stroke(); + } + break; + default: + // Default to grid + for (let i = 0; i <= size; i += spacing) { + ctx.beginPath(); + ctx.moveTo(i, 0); + ctx.lineTo(i, size); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(0, i); + ctx.lineTo(size, i); + ctx.stroke(); + } + } + + ctx.restore(); + + if (blur > 0) { + ctx.filter = `blur(${blur}px)`; + const imageData = ctx.getImageData(0, 0, size, size); + ctx.putImageData(imageData, 0, 0); + ctx.filter = "none"; + } + + return canvas; +} diff --git a/apps/dashboard/lib/r2/index.ts b/apps/dashboard/lib/r2/index.ts new file mode 100644 index 0000000..784cebc --- /dev/null +++ b/apps/dashboard/lib/r2/index.ts @@ -0,0 +1,127 @@ +/** + * Cloudflare R2 Storage Utilities + * + * R2 is S3-compatible object storage. For serving static assets, + * we use public bucket URLs. For uploads, we use the S3 SDK. + */ + +// Environment variables for R2 +// R2_PUBLIC_URL - The public URL for your R2 bucket (e.g., https://assets.yourdomain.com or https://pub-xxx.r2.dev) +// R2_ACCOUNT_ID - Your Cloudflare account ID (for S3 API access) +// R2_ACCESS_KEY_ID - R2 API token access key +// R2_SECRET_ACCESS_KEY - R2 API token secret key +// R2_BUCKET_NAME - Your R2 bucket name + +/** + * Map R2 paths to local public/ directory paths. + * R2 uses paths like "backgrounds/mac/file.jpg" but local files are at "mac/file.jpg" + */ +function mapR2PathToLocal(path: string): string { + // Remove leading slash if present + const cleanPath = path.startsWith('/') ? path.slice(1) : path; + + // Map R2 paths to local public/ paths + const mappings: Record = { + 'backgrounds/': '', + 'overlays/shadow/': 'overlay-shadow/', + 'overlays/arrow/': 'overlay/', + }; + + for (const [r2Prefix, localPrefix] of Object.entries(mappings)) { + if (cleanPath.startsWith(r2Prefix)) { + return `/${localPrefix}${cleanPath.slice(r2Prefix.length)}`; + } + } + + // For paths that don't need mapping (e.g., "assets/...") + return `/${cleanPath}`; +} + +/** + * Get the public URL for an R2 object. + * Uses a same-origin proxy path (/r2-assets/...) to avoid CORS issues + * during canvas capture (e.g. video export with domToCanvas). + * The Next.js rewrite in next.config.ts proxies these to the actual R2 URL. + * + * If R2 is not configured, falls back to serving assets from local public/ directory. + * + * @param path - The object path/key in the bucket (e.g., "backgrounds/image.jpg") + * @returns The proxied URL path or local public path + */ +export function getR2PublicUrl(path: string): string { + const publicUrl = process.env.NEXT_PUBLIC_R2_PUBLIC_URL; + + if (!publicUrl) { + // Fall back to local public/ directory with path mapping + return mapR2PathToLocal(path); + } + + // Remove leading slash from path if present + const cleanPath = path.startsWith('/') ? path.slice(1) : path; + + // Use same-origin proxy to avoid CORS issues with canvas capture + return `/r2-assets/${cleanPath}`; +} + +/** + * Get an optimized image URL from R2 + * Since R2 doesn't have built-in image transformations like Cloudinary, + * we return the original image URL. For optimization, consider using + * Cloudflare Images or a custom Worker with image resizing. + * + * @param options - Image options + * @returns The image URL + */ +export function getR2ImageUrl(options: { + src: string; + width?: number; + height?: number; + quality?: number | 'auto'; + format?: 'auto' | 'webp' | 'avif' | 'jpg' | 'png'; +}): string { + const { src } = options; + + // If it's already a full URL, return as-is + if (src.startsWith('http://') || src.startsWith('https://')) { + return src; + } + + // If it's a blob URL or data URL, return as-is + if (src.startsWith('blob:') || src.startsWith('data:')) { + return src; + } + + // Otherwise, construct the R2 public URL + return getR2PublicUrl(src); +} + +/** + * R2 configuration type + */ +export interface R2Config { + accountId: string; + accessKeyId: string; + secretAccessKey: string; + bucketName: string; + publicUrl: string; +} + +/** + * Get R2 configuration from environment variables + */ +export function getR2Config(): R2Config { + return { + accountId: process.env.R2_ACCOUNT_ID || '', + accessKeyId: process.env.R2_ACCESS_KEY_ID || '', + secretAccessKey: process.env.R2_SECRET_ACCESS_KEY || '', + bucketName: process.env.R2_BUCKET_NAME || '', + publicUrl: process.env.NEXT_PUBLIC_R2_PUBLIC_URL || '', + }; +} + +/** + * Check if R2 is properly configured + */ +export function isR2Configured(): boolean { + return !!process.env.NEXT_PUBLIC_R2_PUBLIC_URL; +} diff --git a/apps/dashboard/lib/r2/r2-backgrounds.ts b/apps/dashboard/lib/r2/r2-backgrounds.ts new file mode 100644 index 0000000..5df3ecc --- /dev/null +++ b/apps/dashboard/lib/r2/r2-backgrounds.ts @@ -0,0 +1,183 @@ +/** + * R2 Background Assets Configuration + * + * These are the paths to background images stored in Cloudflare R2. + * The paths are relative to the R2 bucket root. + */ + +import { getR2PublicUrl } from "."; + +export interface BackgroundCategory { + [category: string]: string[]; +} + +// Background image paths in R2 (with actual file extensions) +export const backgroundCategories: BackgroundCategory = { + assets: [ + "assets/asset-1.jpg", + "assets/asset-2.jpg", + "assets/asset-3.jpg", + "assets/asset-4.jpg", + "assets/asset-5.jpg", + "assets/asset-13.jpg", + "assets/asset-19.jpg", + ], + mac: [ + "backgrounds/mac/mac-asset-1.jpeg", + "backgrounds/mac/mac-asset-2.jpg", + "backgrounds/mac/mac-asset-3.jpg", + "backgrounds/mac/mac-asset-4.jpg", + "backgrounds/mac/mac-asset-5.jpg", + "backgrounds/mac/mac-asset-6.jpeg", + "backgrounds/mac/mac-asset-7.png", + "backgrounds/mac/mac-asset-8.jpg", + "backgrounds/mac/mac-asset-9.jpg", + "backgrounds/mac/mac-asset-10.jpg", + ], + radiant: [ + "backgrounds/radiant/radiant1.jpg", + "backgrounds/radiant/radiant2.jpg", + "backgrounds/radiant/radiant3.jpg", + "backgrounds/radiant/radiant4.jpg", + "backgrounds/radiant/radiant5.jpg", + "backgrounds/radiant/radiant6.jpg", + "backgrounds/radiant/radiant8.jpg", + "backgrounds/radiant/radiant9.jpg", + "backgrounds/radiant/radiant10.jpg", + ], + mesh: [ + "backgrounds/mesh/mesh1.webp", + "backgrounds/mesh/mesh2.webp", + "backgrounds/mesh/mesh3.webp", + "backgrounds/mesh/mesh4.webp", + "backgrounds/mesh/mesh5.webp", + "backgrounds/mesh/mesh6.webp", + "backgrounds/mesh/mesh7.webp", + "backgrounds/mesh/mesh8.webp", + "backgrounds/mesh/Astra.webp", + "backgrounds/mesh/Bliss.webp", + "backgrounds/mesh/Burst.webp", + "backgrounds/mesh/Dusk.webp", + "backgrounds/mesh/Flash.webp", + "backgrounds/mesh/Ghost.webp", + "backgrounds/mesh/Helix.webp", + "backgrounds/mesh/Horizon.webp", + "backgrounds/mesh/Peak.webp", + ], + demo: [ + "backgrounds/demo/demo-1.png", + "backgrounds/demo/demo-2.png", + "backgrounds/demo/demo-3.png", + "backgrounds/demo/demo-4.png", + "backgrounds/demo/demo-5.png", + "backgrounds/demo/demo-6.png", + "backgrounds/demo/demo-7.png", + "backgrounds/demo/demo-8.png", + "backgrounds/demo/demo-9.png", + "backgrounds/demo/demo-10.png", + "backgrounds/demo/demo-11.png", + ], + paper: [ + "backgrounds/paper/01.webp", + "backgrounds/paper/02.webp", + "backgrounds/paper/03.webp", + "backgrounds/paper/21.webp", + "backgrounds/paper/26.webp", + "backgrounds/paper/27.webp", + "backgrounds/paper/31.webp", + "backgrounds/paper/47.webp", + ], + raycast: [ + "backgrounds/raycast/autumnal-peach.webp", + "backgrounds/raycast/blob-red.webp", + "backgrounds/raycast/blob.webp", + "backgrounds/raycast/blossom-2.webp", + "backgrounds/raycast/blue_distortion_1.webp", + "backgrounds/raycast/blue_distortion_2.webp", + "backgrounds/raycast/blushing-fire.webp", + "backgrounds/raycast/bright-rain.webp", + "backgrounds/raycast/chromatic_dark_1.webp", + "backgrounds/raycast/chromatic_dark_2.webp", + "backgrounds/raycast/chromatic_light_1.webp", + "backgrounds/raycast/chromatic_light_2.webp", + "backgrounds/raycast/cube_mono.webp", + "backgrounds/raycast/cube_prod.webp", + "backgrounds/raycast/floss.webp", + "backgrounds/raycast/glass-rainbow.webp", + "backgrounds/raycast/good-vibes.webp", + "backgrounds/raycast/loupe-mono-light.webp", + "backgrounds/raycast/loupe.webp", + "backgrounds/raycast/mono_dark_distortion_1.webp", + "backgrounds/raycast/mono_dark_distortion_2.webp", + "backgrounds/raycast/mono_light_distortion_1.webp", + "backgrounds/raycast/moonrise.webp", + "backgrounds/raycast/red_distortion_2.webp", + "backgrounds/raycast/red_distortion_4.webp", + "backgrounds/raycast/rose-thorn.webp", + ], + pattern: [ + "backgrounds/pattern/1.webp", + "backgrounds/pattern/2.webp", + "backgrounds/pattern/3.webp", + "backgrounds/pattern/4.webp", + "backgrounds/pattern/5.webp", + "backgrounds/pattern/6.webp", + "backgrounds/pattern/7.webp", + "backgrounds/pattern/8.webp", + "backgrounds/pattern/9.webp", + "backgrounds/pattern/10.webp", + "backgrounds/pattern/11.webp", + ], +}; + +// Flatten all background paths for easy lookup +export const backgroundPaths: string[] = + Object.values(backgroundCategories).flat(); + +// Background paths for auth pages +export const SIGN_IN_BACKGROUND_PATH = "backgrounds/mac/mac-asset-7.png"; +export const SIGN_UP_BACKGROUND_PATH = "backgrounds/mac/mac-asset-2.jpg"; + +/** + * Get full R2 URL for a background image + */ +export function getBackgroundUrl(path: string): string { + if (path.startsWith("/")) { + return path; + } + return getR2PublicUrl(path); +} + +/** + * Get thumbnail URL for a background image + * Note: R2 doesn't support on-the-fly image transformations. + * Consider using Cloudflare Images or pre-generating thumbnails. + */ +export function getBackgroundThumbnailUrl(path: string): string { + // Local assets (from /public) are served as-is + if (path.startsWith("/")) { + return path; + } + return getR2PublicUrl(path); +} + +/** + * Check if a path is a known background path + */ +export function isBackgroundPath(path: string): boolean { + return backgroundPaths.includes(path); +} + +/** + * Get all backgrounds for a category + */ +export function getBackgroundsByCategory(category: string): string[] { + return backgroundCategories[category] || []; +} + +/** + * Get all available categories + */ +export function getAvailableCategories(): string[] { + return Object.keys(backgroundCategories); +} diff --git a/apps/dashboard/lib/r2/r2-demo-images.ts b/apps/dashboard/lib/r2/r2-demo-images.ts new file mode 100644 index 0000000..96e62ae --- /dev/null +++ b/apps/dashboard/lib/r2/r2-demo-images.ts @@ -0,0 +1,27 @@ +/** + * R2 Demo Images Configuration + * + * These are the paths to demo images stored in Cloudflare R2. + */ + +import { getR2PublicUrl } from "."; + +export const demoImagePaths: string[] = [ + "backgrounds/demo/demo-1.png", + "backgrounds/demo/demo-2.png", + "backgrounds/demo/demo-3.png", + "backgrounds/demo/demo-4.png", + "backgrounds/demo/demo-5.png", + "backgrounds/demo/demo-6.png", + "backgrounds/demo/demo-11.png", + "backgrounds/demo/demo-8.png", + "backgrounds/demo/demo-9.png", + "backgrounds/demo/demo-10.png", +]; + +/** + * Get full R2 URL for a demo image + */ +export function getDemoImageUrl(path: string): string { + return getR2PublicUrl(path); +} diff --git a/apps/dashboard/lib/r2/r2-overlays.ts b/apps/dashboard/lib/r2/r2-overlays.ts new file mode 100644 index 0000000..1a46854 --- /dev/null +++ b/apps/dashboard/lib/r2/r2-overlays.ts @@ -0,0 +1,107 @@ +/** + * R2 Overlay Assets Configuration + * + * These are the paths to overlay images stored in Cloudflare R2. + * The paths are relative to the R2 bucket root. + */ + +import { getR2PublicUrl } from "."; + +/** + * Arrow overlay paths in R2 + */ +export const ARROW_PATHS = [ + "overlays/arrow/arrow-1.svg", + "overlays/arrow/arrow-2.svg", + "overlays/arrow/arrow-3.svg", + "overlays/arrow/arrow-4.svg", + "overlays/arrow/arrow-5.svg", + "overlays/arrow/arrow-6.svg", + "overlays/arrow/arrow-7.svg", + "overlays/arrow/arrow-8.svg", + "overlays/arrow/arrow-9.svg", + "overlays/arrow/arrow-10.svg", +] as const; + +/** + * Shadow overlay paths in R2 + */ +export const SHADOW_OVERLAY_PATHS = [ + "overlays/shadow/001.webp", + "overlays/shadow/002.webp", + "overlays/shadow/007.webp", + "overlays/shadow/017.webp", + "overlays/shadow/019.webp", + "overlays/shadow/023.webp", + "overlays/shadow/031.webp", + "overlays/shadow/037.webp", + "overlays/shadow/041.webp", + "overlays/shadow/050.webp", + "overlays/shadow/053.webp", + "overlays/shadow/057.webp", + "overlays/shadow/063.webp", + "overlays/shadow/064.webp", + "overlays/shadow/082.webp", + "overlays/shadow/083.webp", + "overlays/shadow/088.webp", + "overlays/shadow/097.webp", + "overlays/shadow/099.webp", +] as const; + +/** + * All overlay paths combined + */ +export const OVERLAY_PATHS = [...ARROW_PATHS, ...SHADOW_OVERLAY_PATHS] as const; + +export type ArrowPath = (typeof ARROW_PATHS)[number]; +export type ShadowOverlayPath = (typeof SHADOW_OVERLAY_PATHS)[number]; +export type OverlayPath = (typeof OVERLAY_PATHS)[number]; + +/** + * Get full R2 URL for an overlay image + */ +export function getOverlayUrl(path: string): string { + return getR2PublicUrl(path); +} + +/** + * Check if a path is a known overlay path + */ +export function isOverlayPath(path: string): boolean { + return (OVERLAY_PATHS as readonly string[]).includes(path); +} + +/** + * Check if a path is an arrow overlay + */ +export function isArrowPath(path: string): boolean { + return (ARROW_PATHS as readonly string[]).includes(path); +} + +/** + * Check if a path is a shadow overlay + */ +export function isShadowOverlayPath(path: string): boolean { + return (SHADOW_OVERLAY_PATHS as readonly string[]).includes(path); +} + +/** + * Get all available overlay paths + */ +export function getAllOverlayPaths(): readonly string[] { + return OVERLAY_PATHS; +} + +/** + * Get all arrow paths + */ +export function getAllArrowPaths(): readonly string[] { + return ARROW_PATHS; +} + +/** + * Get all shadow overlay paths + */ +export function getAllShadowOverlayPaths(): readonly string[] { + return SHADOW_OVERLAY_PATHS; +} diff --git a/apps/dashboard/lib/store/export-utils.ts b/apps/dashboard/lib/store/export-utils.ts new file mode 100644 index 0000000..042f7b9 --- /dev/null +++ b/apps/dashboard/lib/store/export-utils.ts @@ -0,0 +1,56 @@ +// import { domToCanvas } from "modern-screenshot"; + +/** + * Export image with gradient background from an element + * @param elementId - The ID of the element to export + */ +export async function exportImageWithGradient( + elementId: string +): Promise { + const element = document.getElementById(elementId); + + if (!element) { + throw new Error(`Element with id "${elementId}" not found`); + } + + try { + // Use modern-screenshot for better CSS fidelity + // const canvas = await domToCanvas(element, { + // backgroundColor: null, // Transparent background to preserve gradients + // scale: 2, // Higher quality + // width: element.scrollWidth, + // height: element.scrollHeight, + // }); + + // // Convert canvas to blob and download + // return new Promise((resolve, reject) => { + // canvas.toBlob((blob) => { + // if (!blob) { + // reject(new Error("Failed to create blob from canvas")); + // return; + // } + + // // Create download link + // const url = URL.createObjectURL(blob); + // const link = document.createElement("a"); + // link.href = url; + // link.download = `image-${Date.now()}.png`; + // document.body.appendChild(link); + // link.click(); + // document.body.removeChild(link); + + // // Clean up + // URL.revokeObjectURL(url); + // resolve(); + // }, "image/png"); + // }); + return await Promise.reject( + new Error( + "domToCanvas is not implemented. Please uncomment the code and ensure modern-screenshot is installed." + ) + ); + } catch (error) { + console.error("Export failed:", error); + throw error; + } +} diff --git a/apps/dashboard/lib/store/index.ts b/apps/dashboard/lib/store/index.ts new file mode 100644 index 0000000..82c5c20 --- /dev/null +++ b/apps/dashboard/lib/store/index.ts @@ -0,0 +1,2033 @@ +"use client"; + +import React from "react"; +import { temporal } from "zundo"; +import { create } from "zustand"; +// import { +// trackAnimationClipAdd, +// trackAspectRatioChange, +// trackBackgroundChange, +// trackFrameApply, +// trackImageUpload, +// trackOverlayAdd, +// } from "@/lib/analytics"; +// import { +// ANIMATION_PRESETS, +// clonePresetTracks, +// getPresetById, +// } from "@/lib/animation/presets"; +import type { AspectRatioKey } from "@/lib/constants/aspect-ratios"; +import type { + BackgroundConfig, + BackgroundType, +} from "@/lib/constants/backgrounds"; +import { + type GradientKey, + gradientColors, +} from "@/lib/constants/gradient-colors"; +import { solidColors } from "@/lib/constants/solid-colors"; +// import type { +// AnimationClip, +// AnimationTrack, +// Keyframe, +// TimelineState, +// } from "@/types/animation"; +// import { DEFAULT_TIMELINE_STATE } from "@/types/animation"; +import type { Mockup } from "@/types/mockup"; +import { exportImageWithGradient } from "./export-utils"; + +interface TextShadow { + blur: number; + color: string; + enabled: boolean; + offsetX: number; + offsetY: number; +} + +export interface ImageFilters { + blur: number; // 0-20px + brightness: number; // 0-200 (100 = normal) + contrast: number; // 0-200 (100 = normal) + grayscale: number; // 0-100 + hueRotate: number; // 0-360 degrees + invert: number; // 0-100 + saturate: number; // 0-200 (100 = normal) + sepia: number; // 0-100 +} +interface Slide { + duration: number; + id: string; + name: string | null; + src: string; +} +export interface TextOverlay { + color: string; + fontFamily: string; + fontSize: number; + fontWeight: string; + id: string; + isVisible: boolean; + opacity: number; + orientation: "horizontal" | "vertical"; + position: { x: number; y: number }; + text: string; + textShadow: TextShadow; +} + +export interface ImageOverlay { + blur?: number; // Blur amount in pixels (0 = no blur) + flipX: boolean; + flipY: boolean; + id: string; + isCustom?: boolean; // Whether it's a custom uploaded overlay + isVisible: boolean; + layer?: "front" | "back"; // Render in front of or behind the main image + opacity: number; + position: { x: number; y: number }; // Position in pixels relative to canvas + rotation: number; // Rotation in degrees + size: number; // Size in pixels + src: string; +} + +export interface BlurRegion { + blurAmount: number; + id: string; + isVisible: boolean; + position: { x: number; y: number }; + size: { width: number; height: number }; +} + +export type AnnotationToolType = + | "arrow" + | "curved-arrow" + | "rectangle" + | "circle" + | "line" + | "blur"; + +export interface AnnotationShape { + cx?: number; + cy?: number; + fillColor: string; + id: string; + isVisible: boolean; + opacity: number; + strokeColor: string; + strokeWidth: number; + type: AnnotationToolType; + x1: number; + x2: number; + y1: number; + y2: number; +} + +export type ImageStylePreset = + | "default" + | "glass-light" + | "glass-dark" + | "outline" + | "border-light" + | "border-dark"; +export type ShadowPreset = "none" | "hug" | "soft" | "strong"; + +export interface ImageBorder { + color: string; + enabled: boolean; + opacity?: number; + padding?: number; + title?: string; + type: + | "none" + | "arc-light" + | "arc-dark" + | "macos-light" + | "macos-dark" + | "windows-light" + | "windows-dark" + | "photograph" + | "glass-light" + | "glass-dark" + | "outline-light" + | "border-light" + | "border-dark"; + width: number; +} + +export interface ImageShadow { + blur: number; + color: string; + enabled: boolean; + offsetX: number; + offsetY: number; + opacity: number; + spread: number; +} + +// Helper function to parse gradient string and extract colors +function parseGradientColors(gradientStr: string): { + colorA: string; + colorB: string; + direction: number; +} { + // Default fallback + let colorA = "#4168d0"; + let colorB = "#c850c0"; + let direction = 43; + + try { + // Extract angle from linear-gradient(angle, ...) + // biome-ignore lint/performance/useTopLevelRegex: = 2) { + colorA = rgbMatches[0]; + colorB = rgbMatches.at(-1) || colorB; + } else { + // Try hex colors + const hexMatches = gradientStr.match(/#[0-9A-Fa-f]{6}/g); + if (hexMatches && hexMatches.length >= 2) { + colorA = hexMatches[0]; + colorB = hexMatches.at(-1) || colorB; + } + } + } catch (e) { + console.error("Error parsing gradient string:", e); + // Use defaults + } + + return { colorA, colorB, direction }; +} + +// helper function that omits setter types from EditorState and ImageState; only keeps the properties; excludes any functions +export type OmitFunctions = { + // biome-ignore lint/suspicious/noExplicitAny: any ? never : K]: T[K]; +}; + +export interface EditorState { + // Background state (for Konva) + background: { + mode: "solid" | "gradient"; + colorA: string; + colorB: string; + gradientDirection: number; + }; + + // Canvas state + canvas: { + aspectRatio: "square" | "4:3" | "2:1" | "3:2" | "free"; + padding: number; + }; + + // Frame state (same as imageBorder) + frame: { + enabled: boolean; + type: + | "none" + | "arc-light" + | "arc-dark" + | "macos-light" + | "macos-dark" + | "windows-light" + | "windows-dark" + | "photograph" + | "glass-light" + | "glass-dark" + | "outline-light" + | "border-light" + | "border-dark"; + width: number; + color: string; + padding?: number; + title?: string; + opacity?: number; + }; + + // Noise state + noise: { + enabled: boolean; + type: string; + opacity: number; + }; + + // Pattern state + pattern: { + enabled: boolean; + type: string; + scale: number; + spacing: number; + color: string; + rotation: number; + blur: number; + opacity: number; + }; + // Screenshot/image state + screenshot: { + src: string | null; + scale: number; + offsetX: number; + offsetY: number; + rotation: number; + radius: number; + }; + setBackground: (background: Partial) => void; + setCanvas: (canvas: Partial) => void; + setFrame: (frame: Partial) => void; + setNoise: (noise: Partial) => void; + setPattern: (pattern: Partial) => void; + + // Setters + setScreenshot: (screenshot: Partial) => void; + setShadow: (shadow: Partial) => void; + + // Shadow state (for Konva) + shadow: { + enabled: boolean; + elevation: number; + side: "bottom" | "right" | "bottom-right"; + softness: number; + spread: number; + color: string; + intensity: number; + offsetX: number; + offsetY: number; + }; +} + +// Create editor store +export const useEditorStore = create((set, _get) => ({ + screenshot: { + src: null, + scale: 1, + offsetX: 0, + offsetY: 0, + rotation: 0, + radius: 0, + }, + + background: { + mode: "gradient", + colorA: "#4168d0", + colorB: "#c850c0", + gradientDirection: 43, + }, + + shadow: { + enabled: true, + elevation: 12, + side: "bottom-right", + softness: 15, + spread: 3, + color: "rgba(0, 0, 0, 1)", + intensity: 0.5, + offsetX: 5, + offsetY: 8, + }, + + pattern: { + enabled: false, + type: "grid", + scale: 1, + spacing: 20, + color: "#000000", + rotation: 0, + blur: 0, + opacity: 0.5, + }, + + frame: { + enabled: false, + type: "none", + width: 8, + color: "#000000", + padding: 20, + title: "", + }, + + canvas: { + aspectRatio: "free", + padding: 40, + }, + + noise: { + enabled: false, + type: "none", + opacity: 0.5, + }, + + setScreenshot: (screenshot) => { + set((state) => ({ + screenshot: { ...state.screenshot, ...screenshot }, + })); + }, + + setBackground: (background) => { + set((state) => ({ + background: { ...state.background, ...background }, + })); + }, + + setShadow: (shadow) => { + set((state) => ({ + shadow: { ...state.shadow, ...shadow }, + })); + }, + + setPattern: (pattern) => { + set((state) => ({ + pattern: { ...state.pattern, ...pattern }, + })); + }, + + setFrame: (frame) => { + set((state) => ({ + frame: { ...state.frame, ...frame }, + })); + }, + + setCanvas: (canvas) => { + set((state) => ({ + canvas: { ...state.canvas, ...canvas }, + })); + }, + + setNoise: (noise) => { + set((state) => ({ + noise: { ...state.noise, ...noise }, + })); + }, +})); + +// Sync hook to keep editor store in sync with image store +export function useEditorStoreSync() { + const imageStore = useImageStore(); + const editorStore = useEditorStore(); + + // Sync when image store changes + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: { + // Sync screenshot src + if (imageStore.uploadedImageUrl !== editorStore.screenshot.src) { + editorStore.setScreenshot({ src: imageStore.uploadedImageUrl }); + } + + // Sync screenshot scale + if (imageStore.imageScale / 100 !== editorStore.screenshot.scale) { + editorStore.setScreenshot({ scale: imageStore.imageScale / 100 }); + } + + // Sync screenshot radius + if (imageStore.borderRadius !== editorStore.screenshot.radius) { + editorStore.setScreenshot({ radius: imageStore.borderRadius }); + } + + // Sync background + const bgConfig = imageStore.backgroundConfig; + if (bgConfig.type === "gradient") { + const gradientStr = + gradientColors[bgConfig.value as GradientKey] || + gradientColors.vibrant_orange_pink; + const { colorA, colorB, direction } = parseGradientColors(gradientStr); + if ( + editorStore.background.mode !== "gradient" || + editorStore.background.colorA !== colorA || + editorStore.background.colorB !== colorB || + editorStore.background.gradientDirection !== direction + ) { + editorStore.setBackground({ + mode: "gradient", + colorA, + colorB, + gradientDirection: direction, + }); + } + } else if (bgConfig.type === "solid") { + const color = + (solidColors as Record)[bgConfig.value as string] || + "#ffffff"; + if ( + editorStore.background.mode !== "solid" || + editorStore.background.colorA !== color + ) { + editorStore.setBackground({ + mode: "solid", + colorA: color, + colorB: color, + }); + } + } + + // Sync frame + const frame = imageStore.imageBorder; + if ( + editorStore.frame.enabled !== frame.enabled || + editorStore.frame.type !== frame.type || + editorStore.frame.width !== frame.width || + editorStore.frame.color !== frame.color || + editorStore.frame.padding !== frame.padding || + editorStore.frame.title !== frame.title || + editorStore.frame.opacity !== frame.opacity + ) { + editorStore.setFrame({ + enabled: frame.enabled, + type: frame.type, + width: frame.width, + color: frame.color, + padding: frame.padding, + title: frame.title, + opacity: frame.opacity, + }); + } + + // Sync shadow + const shadow = imageStore.imageShadow; + const offsetX = shadow.offsetX || 0; + const offsetY = shadow.offsetY || 0; + const elevation = Math.max(Math.abs(offsetX), Math.abs(offsetY)) || 4; + + let side: "bottom" | "right" | "bottom-right" = "bottom"; + if (Math.abs(offsetX) > Math.abs(offsetY)) { + side = "right"; + } else if (Math.abs(offsetX) > 0 && Math.abs(offsetY) > 0) { + side = "bottom-right"; + } + + if ( + editorStore.shadow.enabled !== shadow.enabled || + editorStore.shadow.softness !== shadow.blur || + editorStore.shadow.spread !== (shadow.spread || 0) || + editorStore.shadow.color !== shadow.color || + editorStore.shadow.offsetX !== offsetX || + editorStore.shadow.offsetY !== offsetY || + editorStore.shadow.intensity !== (shadow.opacity ?? 0.5) + ) { + editorStore.setShadow({ + enabled: shadow.enabled, + softness: shadow.blur, + spread: shadow.spread || 0, + color: shadow.color, + elevation, + side, + intensity: shadow.opacity ?? 0.5, + offsetX, + offsetY, + }); + } + + // Sync canvas aspect ratio + const aspectRatioMap: Record< + AspectRatioKey, + "square" | "4:3" | "2:1" | "3:2" | "free" + > = { + "1_1": "square", + "4_3": "4:3", + "2_1": "2:1", + "3_2": "3:2", + "16_9": "free", + "9_16": "free", + "4_5": "free", + "3_4": "free", + "2_3": "free", + "5_4": "free", + "16_10": "free", + }; + const canvasAspectRatio = + aspectRatioMap[imageStore.selectedAspectRatio] || "free"; + if (editorStore.canvas.aspectRatio !== canvasAspectRatio) { + editorStore.setCanvas({ aspectRatio: canvasAspectRatio }); + } + }, [ + imageStore.uploadedImageUrl, + imageStore.imageScale, + imageStore.borderRadius, + imageStore.backgroundConfig, + imageStore.imageBorder, + imageStore.imageShadow, + imageStore.selectedAspectRatio, + editorStore, + ]); +} + +// Re-export existing ImageState interface and store +export interface ImageState { + activeAnnotationTool: AnnotationToolType | null; + + // UI State + activeRightPanelTab: + | "settings" + | "edit" + | "background" + | "transforms" + | "animate" + | "depth"; + activeSlideId: string | null; + // Animation clips + addAnimationClip: (presetId: string, startTime: number) => void; + addAnnotation: (annotation: Omit) => void; + addBlurRegion: (region: Omit) => void; + addImageOverlay: (overlay: Omit) => void; + // Slideshow actions + addImages: (files: File[]) => void; + addKeyframe: (trackId: string, keyframe: Omit) => void; + addMockup: (mockup: Omit) => void; + addTextOverlay: (overlay: Omit) => void; + // addTrack: (track: Omit) => void; + // animationClips: AnimationClip[]; + annotationDefaults: { + strokeColor: string; + strokeWidth: number; + fillColor: string; + }; + + // Annotations (custom SVG) + annotations: AnnotationShape[]; + applyAnimationPreset: (presetId: string) => void; + backgroundBlur: number; + backgroundBorderRadius: number; + backgroundConfig: BackgroundConfig; + backgroundNoise: number; + + // Blur regions + blurRegions: BlurRegion[]; + borderRadius: number; + browserHeaderSize: number; + browserUrl: string; + canvasDimensions: { + canvasW: number; + canvasH: number; + framedW: number; + framedH: number; + } | null; + clearAnimationClips: () => void; + clearAnnotations: () => void; + clearBlurRegions: () => void; + clearImage: () => void; + clearImageOverlays: () => void; + clearMockups: () => void; + clearTextOverlays: () => void; + clearTimeline: () => void; + customDimensions: { width: number; height: number } | null; + editorMode: "screenshot" | "browser"; + exportImage: () => Promise; + exportSettings: { + quality: "1x" | "2x" | "3x"; + format: "png" | "jpeg" | "webp"; + fileName: string; + }; + imageBorder: ImageBorder; + imageFilters: ImageFilters; + imageName: string | null; + imageOpacity: number; + imageOverlays: ImageOverlay[]; + imageScale: number; + imageShadow: ImageShadow; + imageStylePreset: ImageStylePreset; + // Preview + isPreviewing: boolean; + mockups: Mockup[]; + perspective3D: { + perspective: number; + rotateX: number; + rotateY: number; + rotateZ: number; + translateX: number; + translateY: number; + scale: number; + }; + previewIndex: number; + previewStartedAt: number | null; + removeAnimationClip: (clipId: string) => void; + removeAnnotation: (id: string) => void; + removeBlurRegion: (id: string) => void; + removeImageOverlay: (id: string) => void; + removeKeyframe: (trackId: string, keyframeId: string) => void; + removeMockup: (id: string) => void; + removeSlide: (id: string) => void; + removeTextOverlay: (id: string) => void; + removeTrack: (trackId: string) => void; + reorderImageOverlay: ( + id: string, + direction: "up" | "down" | "top" | "bottom" + ) => void; + resetCanvasSettings: () => void; + resetImageFilters: () => void; + resetSlideshow: () => void; + rulerInterval: number; + selectedAnnotationId: string | null; + selectedAspectRatio: AspectRatioKey; + selectedGradient: GradientKey; + setActiveAnnotationTool: (tool: AnnotationToolType | null) => void; + setActiveRightPanelTab: ( + tab: "settings" | "edit" | "background" | "transforms" | "animate" | "depth" + ) => void; + setActiveSlide: (id: string) => void; + setAnnotationDefaults: ( + defaults: Partial<{ + strokeColor: string; + strokeWidth: number; + fillColor: string; + }> + ) => void; + setAspectRatio: (aspectRatio: AspectRatioKey) => void; + setBackgroundBlur: (blur: number) => void; + setBackgroundBorderRadius: (radius: number) => void; + setBackgroundConfig: (config: BackgroundConfig) => void; + setBackgroundNoise: (noise: number) => void; + setBackgroundOpacity: (opacity: number) => void; + setBackgroundType: (type: BackgroundType) => void; + setBackgroundValue: (value: string) => void; + setBorderRadius: (radius: number) => void; + setBrowserHeaderSize: (size: number) => void; + setBrowserUrl: (url: string) => void; + setCanvasDimensions: (dims: { + canvasW: number; + canvasH: number; + framedW: number; + framedH: number; + }) => void; + setCustomDimensions: (width: number, height: number) => void; + setEditorMode: (mode: "screenshot" | "browser") => void; + setExportSettings: (settings: Partial) => void; + setGradient: (gradient: GradientKey) => void; + setImage: (file: File) => void; + setImageBorder: (border: ImageBorder | Partial) => void; + setImageFilter: (key: keyof ImageFilters, value: number) => void; + setImageOpacity: (opacity: number) => void; + setImageScale: (scale: number) => void; + setImageShadow: (shadow: ImageShadow | Partial) => void; + setImageStylePreset: (preset: ImageStylePreset) => void; + setPerspective3D: (perspective: Partial) => void; + setPlayhead: (time: number) => void; + setRulerInterval: (interval: number) => void; + setSelectedAnnotationId: (id: string | null) => void; + setShadowPreset: (preset: ShadowPreset) => void; + setShowTemplates: (show: boolean) => void; + setShowTimeline: (show: boolean) => void; + // setSlideshow: (updates: Partial) => void; + // setTimeline: (updates: Partial) => void; + // setTimelineDuration: (duration: number) => void; + setUploadedImageUrl: (url: string | null, name: string | null) => void; + shadowPreset: ShadowPreset; + showGrid: boolean; + + // Canvas visual guides + showRulers: boolean; + showTemplates: boolean; + showTimeline: boolean; + // Slideshow + slides: Slide[]; + + slideshow: { + enabled: boolean; + defaultDuration: number; + animation: "none" | "fade" | "slide"; + }; + startPlayback: () => void; + startPreview: () => void; + stopPlayback: () => void; + stopPreview: () => void; + textOverlays: TextOverlay[]; + + // Timeline / Animation + // timeline: TimelineState; + toggleGrid: () => void; + togglePlayback: () => void; + toggleRulers: () => void; + // toggleTimeline: () => void; + // updateAnimationClip: ( + // clipId: string, + // updates: Partial + // ) => void; + updateAnnotation: (id: string, updates: Partial) => void; + updateBlurRegion: (id: string, updates: Partial) => void; + updateImageOverlay: (id: string, updates: Partial) => void; + updateKeyframe: ( + trackId: string, + keyframeId: string, + updates: Partial + ) => void; + updateMockup: (id: string, updates: Partial) => void; + updateTextOverlay: (id: string, updates: Partial) => void; + // updateTrack: (trackId: string, updates: Partial) => void; + uploadedImageUrl: string | null; +} + +export const useImageStore = create()( + temporal((set, get) => ({ + slides: [], + activeSlideId: null, + + slideshow: { + enabled: true, + defaultDuration: 2, + animation: "fade", // 'none' | 'fade' | 'slide' + }, + // setSlideshow: (updates) => { + // set((state) => ({ + // slideshow: { ...state.slideshow, ...updates }, + // })); + // }, + isPreviewing: false, + previewIndex: 0, + previewStartedAt: null, + + uploadedImageUrl: null, + imageName: null, + selectedGradient: "vibrant_orange_pink", + borderRadius: 10, + backgroundBorderRadius: 10, + selectedAspectRatio: "4_3", + customDimensions: null, + backgroundConfig: { + type: "image", + value: "backgrounds/raycast/red_distortion_4.webp", + opacity: 1, + }, + backgroundBlur: 0, + backgroundNoise: 0, + textOverlays: [], + imageOverlays: [], + mockups: [], + imageOpacity: 1, + imageScale: 100, + imageBorder: { + enabled: false, + width: 8, + color: "#000000", + type: "none", + padding: 20, + title: "", + }, + imageShadow: { + enabled: true, + blur: 15, + offsetX: 5, + offsetY: 8, + spread: 3, + color: "rgba(0, 0, 0, 0.6)", + opacity: 0.5, + }, + imageStylePreset: "default" as ImageStylePreset, + shadowPreset: "soft" as ShadowPreset, + perspective3D: { + perspective: 200, // em units, converted to px + rotateX: 0, + rotateY: 0, + rotateZ: 0, + translateX: 0, + translateY: 0, + scale: 1, + }, + imageFilters: { + brightness: 100, + contrast: 100, + grayscale: 0, + blur: 0, + hueRotate: 0, + invert: 0, + saturate: 100, + sepia: 0, + }, + exportSettings: { + quality: "2x", + format: "png", + fileName: "", + }, + + setUploadedImageUrl: (url: string | null, name: string | null = null) => { + set({ + uploadedImageUrl: url, + imageName: name, + }); + // Immediately sync to editor store so canvas updates without + // waiting for the EditorStoreSync useEffect cycle + useEditorStore.getState().setScreenshot({ src: url }); + }, + + setImage: (file: File) => { + const { uploadedImageUrl: oldUrl } = get(); + // Revoke old image URL to prevent memory leaks + if (oldUrl) { + URL.revokeObjectURL(oldUrl); + } + + // Track image upload + // trackImageUpload("file", file.size); + + const imageUrl = URL.createObjectURL(file); + // Reset ALL effects to defaults when uploading a new image + set({ + uploadedImageUrl: imageUrl, + imageName: file.name, + // Reset image settings + imageScale: 100, + imageOpacity: 1, + borderRadius: 10, + backgroundBorderRadius: 10, + // Reset background + backgroundConfig: { + type: "image", + value: "backgrounds/raycast/red_distortion_4.webp", + opacity: 1, + }, + backgroundBlur: 0, + backgroundNoise: 0, + selectedGradient: "vibrant_orange_pink", + // Reset shadow + imageShadow: { + enabled: true, + blur: 15, + offsetX: 5, + offsetY: 8, + spread: 3, + color: "rgba(0, 0, 0, 0.6)", + opacity: 0.5, + }, + // Reset border/frame + imageBorder: { + enabled: false, + width: 8, + color: "#000000", + type: "none", + padding: 20, + title: "", + }, + imageStylePreset: "default" as ImageStylePreset, + shadowPreset: "soft" as ShadowPreset, + // Reset 3D perspective + perspective3D: { + perspective: 200, + rotateX: 0, + rotateY: 0, + rotateZ: 0, + translateX: 0, + translateY: 0, + scale: 1, + }, + // Reset filters + imageFilters: { + brightness: 100, + contrast: 100, + grayscale: 0, + blur: 0, + hueRotate: 0, + invert: 0, + saturate: 100, + sepia: 0, + }, + // Clear overlays + textOverlays: [], + imageOverlays: [], + mockups: [], + // Reset annotations & blur + annotations: [], + activeAnnotationTool: null, + blurRegions: [], + // Reset timeline/animation + // timeline: { ...DEFAULT_TIMELINE_STATE }, + // animationClips: [], + showTimeline: false, + }); + }, + + clearImage: () => { + const { uploadedImageUrl, slides, imageOverlays } = get(); + + // Revoke main image URL + if (uploadedImageUrl) { + URL.revokeObjectURL(uploadedImageUrl); + } + // Revoke all slide URLs to prevent memory leaks + for (const slide of slides) { + if (slide.src) { + URL.revokeObjectURL(slide.src); + } + } + // Revoke custom overlay URLs + for (const overlay of imageOverlays) { + if (overlay.isCustom && overlay.src) { + URL.revokeObjectURL(overlay.src); + } + } + // Clear everything and reset ALL effects to defaults + set({ + uploadedImageUrl: null, + imageName: null, + slides: [], + activeSlideId: null, + isPreviewing: false, + previewIndex: 0, + previewStartedAt: null, + // Reset image settings + imageScale: 100, + imageOpacity: 1, + borderRadius: 10, + backgroundBorderRadius: 10, + // Reset background + backgroundConfig: { + type: "image", + value: "backgrounds/raycast/red_distortion_4.webp", + opacity: 1, + }, + backgroundBlur: 0, + backgroundNoise: 0, + selectedGradient: "vibrant_orange_pink", + // Reset shadow + imageShadow: { + enabled: true, + blur: 15, + offsetX: 5, + offsetY: 8, + spread: 3, + color: "rgba(0, 0, 0, 0.6)", + opacity: 0.5, + }, + // Reset border/frame + imageBorder: { + enabled: false, + width: 8, + color: "#000000", + type: "none", + padding: 20, + title: "", + }, + imageStylePreset: "default" as ImageStylePreset, + shadowPreset: "soft" as ShadowPreset, + // Reset 3D perspective + perspective3D: { + perspective: 200, + rotateX: 0, + rotateY: 0, + rotateZ: 0, + translateX: 0, + translateY: 0, + scale: 1, + }, + // Reset filters + imageFilters: { + brightness: 100, + contrast: 100, + grayscale: 0, + blur: 0, + hueRotate: 0, + invert: 0, + saturate: 100, + sepia: 0, + }, + // Clear overlays + textOverlays: [], + imageOverlays: [], + mockups: [], + // Reset annotations & blur + annotations: [], + activeAnnotationTool: null, + blurRegions: [], + // Reset timeline/animation + // timeline: { ...DEFAULT_TIMELINE_STATE }, + // animationClips: [], + showTimeline: false, + }); + }, + + setGradient: (gradient: GradientKey) => { + set({ selectedGradient: gradient }); + }, + + setBorderRadius: (radius: number) => { + set({ borderRadius: radius }); + }, + resetSlideshow: () => { + set({ + slides: [], + activeSlideId: null, + isPreviewing: false, + previewIndex: 0, + previewStartedAt: null, + }); + }, + setBackgroundBorderRadius: (radius: number) => { + set({ backgroundBorderRadius: radius }); + }, + + setAspectRatio: (aspectRatio: AspectRatioKey) => { + // trackAspectRatioChange(aspectRatio); + set({ selectedAspectRatio: aspectRatio }); + }, + + setCustomDimensions: (width: number, height: number) => { + // trackAspectRatioChange("custom"); + set({ + selectedAspectRatio: "custom", + customDimensions: { width, height }, + }); + }, + + setBackgroundConfig: (config: BackgroundConfig) => { + // trackBackgroundChange(config.type, config.value as string); + set({ backgroundConfig: config }); + }, + + setBackgroundType: (type: BackgroundType) => { + const { backgroundConfig } = get(); + + // If switching to 'image' type and current value is not a valid image, set default to radiant9 + if (type === "image") { + const currentValue = backgroundConfig.value; + const isGradientKey = currentValue in gradientColors; + const isSolidColorKey = currentValue in solidColors; + const isValidImage = + typeof currentValue === "string" && + (currentValue.startsWith("blob:") || + currentValue.startsWith("http") || + currentValue.startsWith("data:") || + // Check if it's a Cloudinary public ID (contains '/' but not a gradient/solid key) + (currentValue.includes("/") && !isGradientKey && !isSolidColorKey)); + + // If current value is a gradient or solid color key, or not a valid image, set default to asset-26 + const newValue = + isGradientKey || isSolidColorKey || !isValidImage + ? "backgrounds/raycast/red_distortion_4.webp" + : currentValue; + + set({ + backgroundConfig: { + ...backgroundConfig, + type, + value: newValue, + }, + }); + } else { + set({ + backgroundConfig: { + ...backgroundConfig, + type, + }, + }); + } + }, + + setBackgroundValue: (value: string) => { + const { backgroundConfig } = get(); + set({ + backgroundConfig: { + ...backgroundConfig, + value, + }, + }); + }, + + setBackgroundOpacity: (opacity: number) => { + const { backgroundConfig } = get(); + set({ + backgroundConfig: { + ...backgroundConfig, + opacity, + }, + }); + }, + + setBackgroundBlur: (blur: number) => { + set({ backgroundBlur: blur }); + }, + + setBackgroundNoise: (noise: number) => { + set({ backgroundNoise: noise }); + }, + + addTextOverlay: (overlay) => { + // trackOverlayAdd("text"); + const id = `text-${Date.now()}-${Math.random() + .toString(36) + // biome-ignore lint/style/noSubstr: ({ + textOverlays: [...state.textOverlays, { ...overlay, id }], + })); + }, + + updateTextOverlay: (id, updates) => { + set((state) => ({ + textOverlays: state.textOverlays.map((overlay) => + overlay.id === id ? { ...overlay, ...updates } : overlay + ), + })); + }, + + removeTextOverlay: (id) => { + set((state) => ({ + textOverlays: state.textOverlays.filter((overlay) => overlay.id !== id), + })); + }, + + clearTextOverlays: () => { + set({ textOverlays: [] }); + }, + + addImageOverlay: (overlay) => { + // trackOverlayAdd("sticker"); + const id = `overlay-${Date.now()}-${Math.random() + .toString(36) + // biome-ignore lint/style/noSubstr: ({ + imageOverlays: [...state.imageOverlays, { blur: 0, ...overlay, id }], + })); + }, + + updateImageOverlay: (id, updates) => { + set((state) => ({ + imageOverlays: state.imageOverlays.map((overlay) => + overlay.id === id ? { ...overlay, ...updates } : overlay + ), + })); + }, + + removeImageOverlay: (id) => { + set((state) => ({ + imageOverlays: state.imageOverlays.filter( + (overlay) => overlay.id !== id + ), + })); + }, + + clearImageOverlays: () => { + set({ imageOverlays: [] }); + }, + + reorderImageOverlay: (id, direction) => { + set((state) => { + const overlays = [...state.imageOverlays]; + const index = overlays.findIndex((o) => o.id === id); + if (index === -1) { + return state; + } + + let newIndex: number; + switch (direction) { + case "up": + newIndex = Math.min(overlays.length - 1, index + 1); + break; + case "down": + newIndex = Math.max(0, index - 1); + break; + case "top": + newIndex = overlays.length - 1; + break; + case "bottom": + newIndex = 0; + break; + default: + newIndex = index; + } + + if (newIndex === index) { + return state; + } + const [item] = overlays.splice(index, 1); + overlays.splice(newIndex, 0, item); + return { imageOverlays: overlays }; + }); + }, + + addMockup: (mockup) => { + const id = `mockup-${Date.now()}-${Math.random() + .toString(36) + // biome-ignore lint/style/noSubstr: ({ + mockups: [...state.mockups, { ...mockup, id }], + })); + }, + + updateMockup: (id, updates) => { + set((state) => ({ + mockups: state.mockups.map((mockup) => + mockup.id === id ? { ...mockup, ...updates } : mockup + ), + })); + }, + + removeMockup: (id) => { + set((state) => ({ + mockups: state.mockups.filter((mockup) => mockup.id !== id), + })); + }, + + clearMockups: () => { + set({ mockups: [] }); + }, + + setImageOpacity: (opacity: number) => { + set({ imageOpacity: opacity }); + }, + + setImageScale: (scale: number) => { + set({ imageScale: scale }); + }, + + setImageBorder: (border: ImageBorder | Partial) => { + const currentBorder = get().imageBorder; + // Track frame changes + if ( + "type" in border && + border.type && + border.type !== currentBorder.type + ) { + // trackFrameApply(border.type); + } + set({ + imageBorder: { + ...currentBorder, + ...border, + }, + }); + }, + + setImageShadow: (shadow: ImageShadow | Partial) => { + const currentShadow = get().imageShadow; + set({ + imageShadow: { + ...currentShadow, + ...shadow, + }, + }); + }, + + setImageStylePreset: (preset: ImageStylePreset) => { + const borderMap: Record> = { + default: { enabled: false, type: "none" }, + "glass-light": { + enabled: true, + type: "glass-light", + opacity: 0.25, + padding: 1, + }, + "glass-dark": { + enabled: true, + type: "glass-dark", + opacity: 0.7, + padding: 1, + }, + outline: { + enabled: true, + type: "outline-light", + opacity: 0.35, + padding: 0.5, + }, + "border-light": { enabled: true, type: "border-light", padding: 1 }, + "border-dark": { enabled: true, type: "border-dark", padding: 1 }, + }; + const currentBorder = get().imageBorder; + set({ + imageStylePreset: preset, + imageBorder: { ...currentBorder, ...borderMap[preset] }, + }); + }, + + setShadowPreset: (preset: ShadowPreset) => { + const shadowMap: Record = { + none: { + enabled: false, + blur: 0, + offsetX: 0, + offsetY: 0, + spread: 0, + color: "rgba(0,0,0,0.6)", + opacity: 0, + }, + hug: { + enabled: true, + blur: 10, + offsetX: 0, + offsetY: 2, + spread: 0, + color: "rgba(0,0,0,0.6)", + opacity: 0.25, + }, + soft: { + enabled: true, + blur: 30, + offsetX: 0, + offsetY: 12, + spread: 5, + color: "rgba(0,0,0,0.6)", + opacity: 0.5, + }, + strong: { + enabled: true, + blur: 60, + offsetX: 0, + offsetY: 24, + spread: 10, + color: "rgba(0,0,0,0.6)", + opacity: 0.8, + }, + }; + set({ + shadowPreset: preset, + imageShadow: shadowMap[preset], + }); + }, + + setPerspective3D: (perspective: Partial) => { + const currentPerspective = get().perspective3D; + set({ + perspective3D: { + ...currentPerspective, + ...perspective, + }, + }); + }, + + setImageFilter: (key: keyof ImageFilters, value: number) => { + const currentFilters = get().imageFilters; + set({ + imageFilters: { + ...currentFilters, + [key]: value, + }, + }); + }, + + resetImageFilters: () => { + set({ + imageFilters: { + brightness: 100, + contrast: 100, + grayscale: 0, + blur: 0, + hueRotate: 0, + invert: 0, + saturate: 100, + sepia: 0, + }, + }); + }, + + resetCanvasSettings: () => { + set({ + imageScale: 100, + imageOpacity: 1, + borderRadius: 10, + backgroundBorderRadius: 10, + backgroundConfig: { + type: "image", + value: "backgrounds/raycast/red_distortion_4.webp", + opacity: 1, + }, + backgroundBlur: 0, + backgroundNoise: 0, + selectedGradient: "vibrant_orange_pink", + imageShadow: { + enabled: true, + blur: 15, + offsetX: 5, + offsetY: 8, + spread: 3, + color: "rgba(0, 0, 0, 0.6)", + opacity: 0.5, + }, + imageBorder: { + enabled: false, + width: 8, + color: "#000000", + type: "none", + padding: 20, + title: "", + }, + imageStylePreset: "default" as ImageStylePreset, + shadowPreset: "soft" as ShadowPreset, + perspective3D: { + perspective: 200, + rotateX: 0, + rotateY: 0, + rotateZ: 0, + translateX: 0, + translateY: 0, + scale: 1, + }, + imageFilters: { + brightness: 100, + contrast: 100, + grayscale: 0, + blur: 0, + hueRotate: 0, + invert: 0, + saturate: 100, + sepia: 0, + }, + textOverlays: [], + imageOverlays: [], + mockups: [], + annotations: [], + activeAnnotationTool: null, + blurRegions: [], + }); + }, + + setExportSettings: (settings: Partial) => { + const currentSettings = get().exportSettings; + set({ + exportSettings: { + ...currentSettings, + ...settings, + }, + }); + }, + + exportImage: async () => { + try { + await exportImageWithGradient("image-render-card"); + } catch (error) { + console.error("Export failed:", error); + throw error; + } + }, + addImages: (files: File[]) => { + // const { slides, slideshow, _timeline } = get(); + const { slides, slideshow } = get(); + + const newSlides = files.map((file) => ({ + id: `slide-${crypto.randomUUID()}`, + src: URL.createObjectURL(file), + name: file.name, + duration: slideshow.defaultDuration, + })); + + const allSlides = [...slides, ...newSlides]; + + // Calculate total slideshow duration based on slides and their durations + // const totalSlideDuration = allSlides.reduce( + // (sum, slide) => sum + slide.duration * 1000, + // 0 + // ); + // const newTimelineDuration = Math.max( + // timeline.duration, + // totalSlideDuration + // ); + + set({ + slides: allSlides, + activeSlideId: get().activeSlideId ?? newSlides[0]?.id ?? null, + uploadedImageUrl: allSlides[0]?.src ?? null, + imageName: allSlides[0]?.name ?? null, + // Auto-show timeline when multiple slides are added + showTimeline: allSlides.length > 1 ? true : get().showTimeline, + // Extend timeline to fit all slides + // timeline: { + // ...timeline, + // duration: newTimelineDuration, + // }, + }); + }, + + setActiveSlide: (id: string) => { + const slide = get().slides.find((s) => s.id === id); + if (!slide) { + return; + } + + set({ + activeSlideId: id, + uploadedImageUrl: slide.src, + imageName: slide.name, + }); + + // Also sync to editorStore for export compatibility + // (React useEffect sync doesn't run during imperative export) + useEditorStore.getState().setScreenshot({ src: slide.src }); + }, + + removeSlide: (id) => { + const { slides, activeSlideId } = get(); + const slide = slides.find((s) => s.id === id); + if (slide) { + URL.revokeObjectURL(slide.src); + } + + const remaining = slides.filter((s) => s.id !== id); + const nextActive = + activeSlideId === id ? (remaining[0]?.id ?? null) : activeSlideId; + const nextSlide = remaining.find((s) => s.id === nextActive); + + set({ + slides: remaining, + activeSlideId: nextActive, + uploadedImageUrl: nextSlide?.src ?? null, + imageName: nextSlide?.name ?? null, + }); + }, + + startPreview: () => { + if (!get().slides.length) { + return; + } + set({ + isPreviewing: true, + previewIndex: 0, + previewStartedAt: Date.now(), + }); + }, + + stopPreview: () => { + set({ + isPreviewing: false, + previewIndex: 0, + previewStartedAt: null, + }); + }, + + // Timeline / Animation state + // timeline: { ...DEFAULT_TIMELINE_STATE }, + showTimeline: false, + animationClips: [], + + // setTimeline: (updates) => { + // set((state) => ({ + // timeline: { ...state.timeline, ...updates }, + // })); + // }, + + setShowTimeline: (show) => { + set({ showTimeline: show }); + }, + + toggleTimeline: () => { + set((state) => ({ showTimeline: !state.showTimeline })); + }, + + setPlayhead: (_time) => { + // set((state) => ({ + // timeline: { + // ...state.timeline, + // playhead: Math.max(0, Math.min(time, state.timeline.duration)), + // }, + // })); + }, + + togglePlayback: () => { + // set((state) => ({ + // timeline: { ...state.timeline, isPlaying: !state.timeline.isPlaying }, + // })); + }, + + startPlayback: () => { + // set((state) => ({ + // timeline: { ...state.timeline, isPlaying: true }, + // })); + }, + + stopPlayback: () => { + // set((state) => ({ + // timeline: { ...state.timeline, isPlaying: false }, + // })); + }, + + addKeyframe: (_trackId, _keyframe) => { + // const id = `kf-${Date.now()}-${/ + // Math.random().toString(36).substr(2, 9)}`; + // set((state) => ({ + // timeline: { + // ...state.timeline, + // tracks: state.timeline.tracks.map((track) => + // track.id === trackId + // ? { + // ...track, + // keyframes: [...track.keyframes, { ...keyframe, id }], + // } + // : track + // ), + // }, + // })); + }, + + updateKeyframe: (_trackId, _keyframeId, _updates) => { + // set((state) => ({ + // timeline: { + // ...state.timeline, + // tracks: state.timeline.tracks.map((track) => + // track.id === trackId + // ? { + // ...track, + // keyframes: track.keyframes.map((kf) => + // kf.id === keyframeId ? { ...kf, ...updates } : kf + // ), + // } + // : track + // ), + // }, + // })); + }, + + removeKeyframe: (_trackId, _keyframeId) => { + // set((state) => ({ + // timeline: { + // ...state.timeline, + // tracks: state.timeline.tracks.map((track) => + // track.id === trackId + // ? { + // ...track, + // keyframes: track.keyframes.filter( + // (kf) => kf.id !== keyframeId + // ), + // } + // : track + // ), + // }, + // })); + }, + + addTrack: () => { + // _track; + // const id = `track-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + // set((state) => ({ + // timeline: { + // ...state.timeline, + // tracks: [...state.timeline.tracks, { ...track, id }], + // }, + // })); + }, + + updateTrack: () => { + // _trackId, _updates; + // set((state) => ({ + // timeline: { + // ...state.timeline, + // tracks: state.timeline.tracks.map((track) => + // track.id === trackId ? { ...track, ...updates } : track + // ), + // }, + // })); + }, + + removeTrack: (_trackId) => { + // set((state) => ({ + // timeline: { + // ...state.timeline, + // tracks: state.timeline.tracks.filter((track) => track.id !== trackId), + // }, + // })); + }, + + applyAnimationPreset: (_presetId) => { + // const preset = getPresetById(presetId); + // if (!preset) { + // return; + // } + // const tracks = clonePresetTracks(preset); + // set((state) => ({ + // timeline: { + // ...state.timeline, + // duration: preset.duration, + // tracks, + // playhead: 0, + // isPlaying: false, + // }, + // })); + }, + + clearTimeline: () => { + // set({ + // timeline: { ...DEFAULT_TIMELINE_STATE }, + // }); + }, + + setTimelineDuration: () => { + // _duration; + // set((state) => { + // const newDuration = Math.max(500, duration); + // // Clamp animation clips to fit within the new duration + // const clampedClips = state.animationClips.map((clip) => { + // // Ensure clip doesn't extend beyond new duration + // const maxStartTime = Math.max(0, newDuration - 200); // Minimum clip duration of 200ms + // const clampedStart = Math.min(clip.startTime, maxStartTime); + // const maxDuration = newDuration - clampedStart; + // const clampedDuration = Math.min(clip.duration, maxDuration); + // return { + // ...clip, + // startTime: clampedStart, + // duration: Math.max(200, clampedDuration), + // }; + // }); + // return { + // animationClips: clampedClips, + // timeline: { + // ...state.timeline, + // duration: newDuration, + // playhead: Math.min(state.timeline.playhead, newDuration), + // }, + // }; + // }); + }, + + // Animation clips + addAnimationClip: (_presetId, _startTime) => { + // const preset = ANIMATION_PRESETS.find((p) => p.id === presetId); + // if (!preset) { + // return; + // } + // trackAnimationClipAdd(); + // in above function presetId, preset.name, preset.duration; + // const id = `clip-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + // Brand-matching green color palette + // const colors = ["#c9ff2e", "#10B981", "#22c55e", "#84cc16", "#34d399"]; + // const color = colors[Math.floor(Math.random() * colors.length)]; + // const newClip: AnimationClip = { + // id, + // presetId, + // name: preset.name, + // startTime, + // duration: preset.duration, + // color, + // }; + // Clone preset tracks with startTime offset and link to clip + // const tracks = clonePresetTracks(preset, { startTime, clipId: id }); + // set((state) => ({ + // animationClips: [...state.animationClips, newClip], + // timeline: { + // ...state.timeline, + // tracks: [...state.timeline.tracks, ...tracks], + // }, + // showTimeline: true, + // })); + }, + + updateAnimationClip: () => { + // _clipId, _updates; + // set((state) => { + // const existingClip = state.animationClips.find((c) => c.id === clipId); + // if (!existingClip) { + // return state; + // } + // const newClip = { ...existingClip, ...updates }; + // // If startTime or duration changed, update the corresponding track keyframes + // const startTimeChanged = + // updates.startTime !== undefined && + // updates.startTime !== existingClip.startTime; + // const durationChanged = + // updates.duration !== undefined && + // updates.duration !== existingClip.duration; + // let updatedTracks = state.timeline.tracks; + // if (startTimeChanged || durationChanged) { + // updatedTracks = state.timeline.tracks.map((track) => { + // if (track.clipId !== clipId) { + // return track; + // } + // const originalDuration = + // track.originalDuration || existingClip.duration; + // const newStartTime = updates.startTime ?? existingClip.startTime; + // const newDuration = updates.duration ?? existingClip.duration; + // const oldStartTime = existingClip.startTime; + // // Calculate time scaling factor if duration changed + // const scaleFactor = durationChanged + // ? newDuration / existingClip.duration + // : 1; + // return { + // ...track, + // keyframes: track.keyframes.map((kf) => { + // // First, get the relative time within the clip (remove old startTime offset) + // const relativeTime = kf.time - oldStartTime; + // // Scale the relative time if duration changed + // const scaledRelativeTime = relativeTime * scaleFactor; + // // Add the new start time offset + // const newTime = scaledRelativeTime + newStartTime; + // return { + // ...kf, + // time: Math.max(0, newTime), + // }; + // }), + // }; + // }); + // } + // return { + // animationClips: state.animationClips.map((clip) => + // clip.id === clipId ? newClip : clip + // ), + // timeline: { + // ...state.timeline, + // tracks: updatedTracks, + // }, + // }; + // }); + }, + + removeAnimationClip: (_clipId) => { + // set((state) => ({ + // animationClips: state.animationClips.filter( + // (clip) => clip.id !== clipId + // ), + // timeline: { + // ...state.timeline, + // // Remove tracks associated with this clip + // tracks: state.timeline.tracks.filter( + // (track) => track.clipId !== clipId + // ), + // }, + // })); + }, + + clearAnimationClips: () => { + // set({ + // animationClips: [], + // timeline: { ...DEFAULT_TIMELINE_STATE }, + // }); + }, + + // Annotations (custom SVG) + annotations: [], + activeAnnotationTool: null, + selectedAnnotationId: null, + annotationDefaults: { + strokeColor: "#ef4444", + strokeWidth: 6, + fillColor: "transparent", + }, + addAnnotation: (annotation) => { + const id = `ann-${Date.now()}-${ + // biome-ignore lint/style/noSubstr: ({ + annotations: [...state.annotations, { ...annotation, id }], + selectedAnnotationId: id, + })); + }, + updateAnnotation: (id, updates) => { + set((state) => ({ + annotations: state.annotations.map((a) => + a.id === id ? { ...a, ...updates } : a + ), + })); + }, + removeAnnotation: (id) => { + set((state) => ({ + annotations: state.annotations.filter((a) => a.id !== id), + selectedAnnotationId: + state.selectedAnnotationId === id ? null : state.selectedAnnotationId, + })); + }, + clearAnnotations: () => + set({ annotations: [], selectedAnnotationId: null }), + setActiveAnnotationTool: (tool) => set({ activeAnnotationTool: tool }), + setSelectedAnnotationId: (id) => set({ selectedAnnotationId: id }), + setAnnotationDefaults: (defaults) => { + set((state) => ({ + annotationDefaults: { ...state.annotationDefaults, ...defaults }, + })); + }, + + // Blur regions + blurRegions: [], + addBlurRegion: (region) => { + const id = `blur-${Date.now()}-${ + // biome-ignore lint/style/noSubstr: ({ + blurRegions: [...state.blurRegions, { ...region, id }], + })); + }, + updateBlurRegion: (id, updates) => { + set((state) => ({ + blurRegions: state.blurRegions.map((r) => + r.id === id ? { ...r, ...updates } : r + ), + })); + }, + removeBlurRegion: (id) => { + set((state) => ({ + blurRegions: state.blurRegions.filter((r) => r.id !== id), + })); + }, + clearBlurRegions: () => set({ blurRegions: [] }), + + // UI State + activeRightPanelTab: "edit", + setActiveRightPanelTab: (tab) => { + set({ activeRightPanelTab: tab }); + }, + showTemplates: false, + setShowTemplates: (show) => { + set({ showTemplates: show }); + }, + editorMode: "screenshot", + setEditorMode: (mode) => { + const currentBorder = get().imageBorder; + if (mode === "browser") { + // Apply default browser frame (Chrome Dark) if no browser frame is active + const isBrowserFrame = [ + "macos-light", + "macos-dark", + "windows-light", + "windows-dark", + ].includes(currentBorder.type); + if (!isBrowserFrame) { + set({ + editorMode: mode, + imageBorder: { + ...currentBorder, + enabled: true, + type: "windows-dark", + title: get().browserUrl || "", + }, + }); + return; + } + } else { + // Switching back to screenshot: disable browser frame + const isBrowserFrame = [ + "macos-light", + "macos-dark", + "windows-light", + "windows-dark", + ].includes(currentBorder.type); + if (isBrowserFrame) { + set({ + editorMode: mode, + imageBorder: { + ...currentBorder, + enabled: false, + type: "none", + }, + }); + return; + } + } + set({ editorMode: mode }); + }, + browserUrl: "", + setBrowserUrl: (url) => { + const currentBorder = get().imageBorder; + set({ + browserUrl: url, + imageBorder: { ...currentBorder, title: url }, + }); + }, + browserHeaderSize: 100, + setBrowserHeaderSize: (size) => { + set({ browserHeaderSize: size }); + }, + canvasDimensions: null, + setCanvasDimensions: (dims) => { + set({ canvasDimensions: dims }); + }, + + showRulers: false, + showGrid: false, + rulerInterval: 100, + toggleRulers: () => set((state) => ({ showRulers: !state.showRulers })), + toggleGrid: () => set((state) => ({ showGrid: !state.showGrid })), + setRulerInterval: (interval) => + set({ + rulerInterval: Math.max( + 1, + Math.round(Number.isFinite(interval) ? interval : 100) + ), + }), + })) +); diff --git a/apps/dashboard/lib/workers/export-worker-service.ts b/apps/dashboard/lib/workers/export-worker-service.ts new file mode 100644 index 0000000..8a52cf8 --- /dev/null +++ b/apps/dashboard/lib/workers/export-worker-service.ts @@ -0,0 +1,657 @@ +/** + * Export Worker Service + * + * Provides a clean API for main thread to communicate with the export web worker. + * Handles worker lifecycle, message passing, and provides Promise-based APIs. + */ + +import type { + BlurPayload, + CompositePayload, + ConvertFormatPayload, + ConvertFormatResult, + ExportWorkerMessageType, + ExportWorkerRequest, + ExportWorkerResponse, + NoisePayload, + OpacityPayload, +} from "./export.worker"; + +// Re-export types for consumers +export type { + BlurPayload, + CompositePayload, + ConvertFormatPayload, + ConvertFormatResult, + NoisePayload, + OpacityPayload, +}; + +type PendingRequest = { + resolve: (result: any) => void; + reject: (error: Error) => void; +}; + +class ExportWorkerService { + private worker: Worker | null = null; + private pendingRequests: Map = new Map(); + private isReady = false; + private readyPromise: Promise | null = null; + private messageId = 0; + private initializationAttempted = false; + + /** + * Check if Web Workers are supported in current environment + */ + private isWorkerSupported(): boolean { + return typeof window !== "undefined" && typeof Worker !== "undefined"; + } + + /** + * Initialize the worker + */ + private initializeWorker(): Promise { + if (this.worker || this.initializationAttempted) { + return this.readyPromise || Promise.resolve(); + } + + this.initializationAttempted = true; + + if (!this.isWorkerSupported()) { + console.warn( + "Web Workers not supported, operations will run on main thread" + ); + this.isReady = false; + return; + } + + this.readyPromise = new Promise((resolve, reject) => { + try { + // Create worker using Webpack's worker-loader syntax + this.worker = new Worker( + new URL("./export.worker.ts", import.meta.url), + { type: "module" } + ); + + const timeout = setTimeout(() => { + console.warn( + "Worker initialization timeout, falling back to main thread" + ); + this.isReady = false; + resolve(); + }, 5000); + + this.worker.onmessage = (event: MessageEvent) => { + const data = event.data; + + // Handle ready signal + if (data.type === "ready") { + clearTimeout(timeout); + this.isReady = true; + resolve(); + return; + } + + // Handle response messages + const response = data as ExportWorkerResponse; + const pending = this.pendingRequests.get(response.id); + + if (pending) { + this.pendingRequests.delete(response.id); + + if (response.success) { + // Reconstruct ImageData from transferred buffer if needed + if ( + response.result && + response.result.data && + response.result.width && + response.result.height + ) { + const { data: dataArray, width, height } = response.result; + try { + // Create a new Uint8ClampedArray with a fresh ArrayBuffer + let clampedArray: Uint8ClampedArray; + + if (dataArray instanceof Uint8ClampedArray) { + // Copy to a new array to ensure we have an ArrayBuffer + clampedArray = new Uint8ClampedArray(dataArray.length); + clampedArray.set(dataArray); + } else if (ArrayBuffer.isView(dataArray)) { + // Handle other typed array views + const view = dataArray as Uint8Array; + clampedArray = new Uint8ClampedArray(view.length); + clampedArray.set(view); + } else if (Array.isArray(dataArray)) { + // Handle plain array + clampedArray = new Uint8ClampedArray(dataArray); + } else { + pending.resolve(response.result); + return; + } + + // Use type assertion to handle TypeScript strict mode + const imageData = new ImageData( + clampedArray as unknown as Uint8ClampedArray, + width, + height + ); + pending.resolve(imageData); + } catch { + pending.resolve(response.result); + } + } else { + pending.resolve(response.result); + } + } else { + pending.reject( + new Error(response.error || "Worker operation failed") + ); + } + } + }; + + this.worker.onerror = (error) => { + clearTimeout(timeout); + console.error("Worker error:", error); + this.isReady = false; + + // Reject all pending requests + for (const [id, pending] of this.pendingRequests) { + pending.reject(new Error("Worker encountered an error")); + this.pendingRequests.delete(id); + } + + resolve(); // Resolve initialization so fallback can be used + }; + } catch (error) { + console.warn( + "Failed to create worker, falling back to main thread:", + error + ); + this.isReady = false; + resolve(); + } + }); + + return this.readyPromise; + } + + /** + * Generate a unique message ID + */ + private generateId(): string { + return `msg_${++this.messageId}_${Date.now()}`; + } + + /** + * Send a message to the worker and wait for response + */ + private async sendMessage( + type: ExportWorkerMessageType, + payload: any, + transferables?: Transferable[] + ): Promise { + await this.initializeWorker(); + + if (!(this.isReady && this.worker)) { + throw new Error("Worker not available"); + } + + const id = this.generateId(); + const request: ExportWorkerRequest = { id, type, payload }; + + return new Promise((resolve, reject) => { + this.pendingRequests.set(id, { resolve, reject }); + + // Set timeout for the request + const timeout = setTimeout(() => { + this.pendingRequests.delete(id); + reject(new Error(`Worker request timeout: ${type}`)); + }, 30_000); // 30 second timeout + + // Update resolve/reject to clear timeout + this.pendingRequests.set(id, { + resolve: (result) => { + clearTimeout(timeout); + resolve(result); + }, + reject: (error) => { + clearTimeout(timeout); + reject(error); + }, + }); + + if (transferables && transferables.length > 0) { + this.worker!.postMessage(request, transferables); + } else { + this.worker!.postMessage(request); + } + }); + } + + /** + * Check if the worker is ready + */ + async isWorkerReady(): Promise { + await this.initializeWorker(); + return this.isReady; + } + + /** + * Generate noise texture using worker + * Falls back to main thread if worker unavailable + */ + async generateNoiseTexture( + width: number, + height: number, + intensity: number + ): Promise { + await this.initializeWorker(); + + // If worker not available, run on main thread + if (!(this.isReady && this.worker)) { + return this.generateNoiseTextureFallback(width, height, intensity); + } + + try { + return await this.sendMessage("generateNoise", { + width, + height, + intensity, + } as NoisePayload); + } catch (error) { + console.warn("Worker noise generation failed, using fallback:", error); + return this.generateNoiseTextureFallback(width, height, intensity); + } + } + + /** + * Fallback noise generation on main thread + */ + private generateNoiseTextureFallback( + width: number, + height: number, + intensity: number + ): ImageData { + const imageData = new ImageData(width, height); + const data = imageData.data; + const stdDev = intensity * 50; + + for (let i = 0; i < data.length; i += 4) { + let u = 0, + v = 0; + while (u === 0) { + u = Math.random(); + } + while (v === 0) { + v = Math.random(); + } + const z = Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v); + const noise = z * stdDev + 128; + const value = Math.max(0, Math.min(255, Math.round(noise))); + + data[i] = value; + data[i + 1] = value; + data[i + 2] = value; + data[i + 3] = 255; + } + + return imageData; + } + + /** + * Apply blur to image data using worker + * Falls back to main thread if worker unavailable + */ + async applyBlur( + imageData: ImageData, + blurAmount: number + ): Promise { + if (blurAmount <= 0) { + return imageData; + } + + await this.initializeWorker(); + + if (!(this.isReady && this.worker)) { + return this.applyBlurFallback(imageData, blurAmount); + } + + try { + // Create a copy of the data to transfer + const dataCopy = new Uint8ClampedArray(imageData.data); + const payload: BlurPayload = { + imageData: { + data: dataCopy, + width: imageData.width, + height: imageData.height, + } as unknown as ImageData, + blurAmount, + width: imageData.width, + height: imageData.height, + }; + + return await this.sendMessage("applyBlur", payload, [ + dataCopy.buffer, + ]); + } catch (error) { + console.warn("Worker blur failed, using fallback:", error); + return this.applyBlurFallback(imageData, blurAmount); + } + } + + /** + * Fallback blur on main thread + */ + private applyBlurFallback( + imageData: ImageData, + blurAmount: number + ): ImageData { + const canvas = document.createElement("canvas"); + canvas.width = imageData.width; + canvas.height = imageData.height; + const ctx = canvas.getContext("2d"); + + if (!ctx) { + return imageData; + } + + ctx.putImageData(imageData, 0, 0); + + const blurredCanvas = document.createElement("canvas"); + blurredCanvas.width = imageData.width; + blurredCanvas.height = imageData.height; + const blurredCtx = blurredCanvas.getContext("2d"); + + if (!blurredCtx) { + return imageData; + } + + blurredCtx.filter = `blur(${blurAmount}px)`; + blurredCtx.drawImage(canvas, 0, 0); + + return blurredCtx.getImageData(0, 0, imageData.width, imageData.height); + } + + /** + * Apply opacity to image data using worker + * Falls back to main thread if worker unavailable + */ + async applyOpacity( + imageData: ImageData, + opacity: number + ): Promise { + if (opacity >= 1) { + return imageData; + } + + await this.initializeWorker(); + + if (!(this.isReady && this.worker)) { + return this.applyOpacityFallback(imageData, opacity); + } + + try { + const dataCopy = new Uint8ClampedArray(imageData.data); + const payload: OpacityPayload = { + imageData: { + data: dataCopy, + width: imageData.width, + height: imageData.height, + } as unknown as ImageData, + opacity, + width: imageData.width, + height: imageData.height, + }; + + return await this.sendMessage("applyOpacity", payload, [ + dataCopy.buffer, + ]); + } catch (error) { + console.warn("Worker opacity failed, using fallback:", error); + return this.applyOpacityFallback(imageData, opacity); + } + } + + /** + * Fallback opacity on main thread + */ + private applyOpacityFallback( + imageData: ImageData, + opacity: number + ): ImageData { + const result = new ImageData(imageData.width, imageData.height); + const srcData = imageData.data; + const destData = result.data; + + for (let i = 0; i < srcData.length; i += 4) { + destData[i] = srcData[i]; + destData[i + 1] = srcData[i + 1]; + destData[i + 2] = srcData[i + 2]; + destData[i + 3] = Math.round(srcData[i + 3] * opacity); + } + + return result; + } + + /** + * Composite two image data layers using worker + * Falls back to main thread if worker unavailable + */ + async composite( + baseImageData: ImageData, + overlayImageData: ImageData, + blendMode: "normal" | "overlay" | "multiply" | "screen" = "normal", + overlayOpacity = 1 + ): Promise { + await this.initializeWorker(); + + if (!(this.isReady && this.worker)) { + return this.compositeFallback( + baseImageData, + overlayImageData, + blendMode, + overlayOpacity + ); + } + + try { + const baseDataCopy = new Uint8ClampedArray(baseImageData.data); + const overlayDataCopy = new Uint8ClampedArray(overlayImageData.data); + + const payload: CompositePayload = { + baseImageData: { + data: baseDataCopy, + width: baseImageData.width, + height: baseImageData.height, + } as unknown as ImageData, + overlayImageData: { + data: overlayDataCopy, + width: overlayImageData.width, + height: overlayImageData.height, + } as unknown as ImageData, + blendMode, + overlayOpacity, + width: baseImageData.width, + height: baseImageData.height, + }; + + return await this.sendMessage("composite", payload, [ + baseDataCopy.buffer, + overlayDataCopy.buffer, + ]); + } catch (error) { + console.warn("Worker composite failed, using fallback:", error); + return this.compositeFallback( + baseImageData, + overlayImageData, + blendMode, + overlayOpacity + ); + } + } + + /** + * Fallback composite on main thread + */ + private compositeFallback( + baseImageData: ImageData, + overlayImageData: ImageData, + blendMode: string, + overlayOpacity: number + ): ImageData { + const canvas = document.createElement("canvas"); + canvas.width = baseImageData.width; + canvas.height = baseImageData.height; + const ctx = canvas.getContext("2d"); + + if (!ctx) { + return baseImageData; + } + + // Draw base + ctx.putImageData(baseImageData, 0, 0); + + // Create overlay canvas + const overlayCanvas = document.createElement("canvas"); + overlayCanvas.width = overlayImageData.width; + overlayCanvas.height = overlayImageData.height; + const overlayCtx = overlayCanvas.getContext("2d"); + + if (!overlayCtx) { + return baseImageData; + } + + overlayCtx.putImageData(overlayImageData, 0, 0); + + // Create pattern for tiling + const pattern = ctx.createPattern(overlayCanvas, "repeat"); + + if (pattern) { + ctx.save(); + ctx.globalCompositeOperation = blendMode as GlobalCompositeOperation; + ctx.globalAlpha = overlayOpacity; + ctx.fillStyle = pattern; + ctx.fillRect(0, 0, baseImageData.width, baseImageData.height); + ctx.restore(); + } + + return ctx.getImageData(0, 0, baseImageData.width, baseImageData.height); + } + + /** + * Convert image format using worker (OffscreenCanvas) + * Falls back to main thread canvas if worker unavailable + */ + async convertFormat( + canvas: HTMLCanvasElement, + format: "png" | "jpeg" | "webp", + quality: number + ): Promise<{ blob: Blob; mimeType: string; fileSize: number }> { + await this.initializeWorker(); + + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new Error("Failed to get 2D context from canvas"); + } + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + + // If worker not available, use main thread fallback + if (!(this.isReady && this.worker)) { + return this.convertFormatFallback(canvas, format, quality); + } + + try { + const dataCopy = new Uint8ClampedArray(imageData.data); + const payload: ConvertFormatPayload = { + imageData: { + data: dataCopy, + width: imageData.width, + height: imageData.height, + } as unknown as ImageData, + format, + quality, + width: canvas.width, + height: canvas.height, + }; + + const result = await this.sendMessage( + "convertFormat", + payload, + [dataCopy.buffer] + ); + + // Convert ArrayBuffer back to Blob + const blob = new Blob([result.blob], { type: result.mimeType }); + + return { + blob, + mimeType: result.mimeType, + fileSize: result.fileSize, + }; + } catch (error) { + console.warn("Worker format conversion failed, using fallback:", error); + return this.convertFormatFallback(canvas, format, quality); + } + } + + /** + * Fallback format conversion on main thread + */ + private async convertFormatFallback( + canvas: HTMLCanvasElement, + format: "png" | "jpeg" | "webp", + quality: number + ): Promise<{ blob: Blob; mimeType: string; fileSize: number }> { + const mimeType = + format === "png" + ? "image/png" + : format === "webp" + ? "image/webp" + : "image/jpeg"; + + return new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => { + if (blob) { + resolve({ + blob, + mimeType, + fileSize: blob.size, + }); + } else { + reject(new Error("Failed to convert canvas to blob")); + } + }, + mimeType, + format === "png" ? undefined : quality + ); + }); + } + + /** + * Terminate the worker + */ + terminate(): void { + if (this.worker) { + this.worker.terminate(); + this.worker = null; + this.isReady = false; + this.initializationAttempted = false; + this.readyPromise = null; + + // Reject all pending requests + for (const [id, pending] of this.pendingRequests) { + pending.reject(new Error("Worker terminated")); + this.pendingRequests.delete(id); + } + } + } +} + +// Export singleton instance +export const exportWorkerService = new ExportWorkerService(); + +// Export class for testing +export { ExportWorkerService }; diff --git a/apps/dashboard/lib/workers/export.worker.ts b/apps/dashboard/lib/workers/export.worker.ts new file mode 100644 index 0000000..87835e4 --- /dev/null +++ b/apps/dashboard/lib/workers/export.worker.ts @@ -0,0 +1,435 @@ +/** + * Export Web Worker + * + * Handles heavy image processing operations off the main thread: + * - Noise texture generation (Gaussian noise) + * - Canvas blur operations + * - Canvas opacity operations + * - Image compositing + * + * Uses OffscreenCanvas for canvas operations in the worker context. + */ + +/* eslint-disable no-restricted-globals */ + +// Worker message types +export type ExportWorkerMessageType = + | 'generateNoise' + | 'applyBlur' + | 'applyOpacity' + | 'composite' + | 'processImageData' + | 'convertFormat'; + +export interface ExportWorkerRequest { + id: string; + type: ExportWorkerMessageType; + payload: any; +} + +export interface ExportWorkerResponse { + id: string; + type: ExportWorkerMessageType; + success: boolean; + result?: any; + error?: string; +} + +// Noise generation payload +export interface NoisePayload { + width: number; + height: number; + intensity: number; +} + +// Blur payload +export interface BlurPayload { + imageData: ImageData; + blurAmount: number; + width: number; + height: number; +} + +// Opacity payload +export interface OpacityPayload { + imageData: ImageData; + opacity: number; + width: number; + height: number; +} + +// Composite payload +export interface CompositePayload { + baseImageData: ImageData; + overlayImageData: ImageData; + blendMode: 'normal' | 'overlay' | 'multiply' | 'screen'; + overlayOpacity: number; + width: number; + height: number; +} + +// Format conversion payload +export interface ConvertFormatPayload { + imageData: ImageData; + format: 'png' | 'jpeg' | 'webp'; + quality: number; // 0-1 for jpeg/webp + width: number; + height: number; +} + +// Format conversion result +export interface ConvertFormatResult { + blob: ArrayBuffer; + mimeType: string; + fileSize: number; +} + +// Worker context type +const ctx: Worker = self as unknown as Worker; + +/** + * Generate Gaussian (normal) distributed random number using Box-Muller transform + */ +function gaussianRandom(mean: number = 0, stdDev: number = 1): number { + let u = 0, v = 0; + while (u === 0) u = Math.random(); + while (v === 0) v = Math.random(); + const z = Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v); + return z * stdDev + mean; +} + +/** + * Generate noise texture ImageData + */ +function generateNoiseTexture(width: number, height: number, intensity: number): ImageData { + const imageData = new ImageData(width, height); + const data = imageData.data; + + const stdDev = intensity * 50; + + for (let i = 0; i < data.length; i += 4) { + const noise = gaussianRandom(128, stdDev); + const value = Math.max(0, Math.min(255, Math.round(noise))); + + data[i] = value; // R + data[i + 1] = value; // G + data[i + 2] = value; // B + data[i + 3] = 255; // A + } + + return imageData; +} + +/** + * Apply blur using OffscreenCanvas + * Note: OffscreenCanvas filter support varies by browser + */ +function applyBlur( + imageData: ImageData, + blurAmount: number, + width: number, + height: number +): ImageData { + if (blurAmount <= 0) { + return imageData; + } + + // Check if OffscreenCanvas is available + if (typeof OffscreenCanvas === 'undefined') { + // Fallback: return original data if OffscreenCanvas not available + console.warn('OffscreenCanvas not available, returning original image data'); + return imageData; + } + + const canvas = new OffscreenCanvas(width, height); + const canvasCtx = canvas.getContext('2d'); + + if (!canvasCtx) { + return imageData; + } + + // Put the image data + canvasCtx.putImageData(imageData, 0, 0); + + // Create a temporary canvas to draw blurred result + const blurredCanvas = new OffscreenCanvas(width, height); + const blurredCtx = blurredCanvas.getContext('2d'); + + if (!blurredCtx) { + return imageData; + } + + // Apply blur filter + blurredCtx.filter = `blur(${blurAmount}px)`; + blurredCtx.drawImage(canvas, 0, 0); + blurredCtx.filter = 'none'; + + // Get the blurred image data + return blurredCtx.getImageData(0, 0, width, height); +} + +/** + * Apply opacity to image data + */ +function applyOpacity( + imageData: ImageData, + opacity: number, + width: number, + height: number +): ImageData { + if (opacity >= 1) { + return imageData; + } + + const result = new ImageData(width, height); + const srcData = imageData.data; + const destData = result.data; + + if (opacity <= 0) { + // Return transparent image + return result; + } + + // Apply opacity to alpha channel + for (let i = 0; i < srcData.length; i += 4) { + destData[i] = srcData[i]; // R + destData[i + 1] = srcData[i + 1]; // G + destData[i + 2] = srcData[i + 2]; // B + destData[i + 3] = Math.round(srcData[i + 3] * opacity); // A + } + + return result; +} + +/** + * Blend two pixels based on blend mode + */ +function blendPixel( + baseR: number, baseG: number, baseB: number, baseA: number, + overlayR: number, overlayG: number, overlayB: number, overlayA: number, + blendMode: string, + overlayOpacity: number +): [number, number, number, number] { + // Apply overlay opacity + overlayA = overlayA * overlayOpacity; + + if (overlayA === 0) { + return [baseR, baseG, baseB, baseA]; + } + + let r: number, g: number, b: number; + + switch (blendMode) { + case 'overlay': + // Overlay blend mode + r = baseR < 128 ? (2 * baseR * overlayR) / 255 : 255 - (2 * (255 - baseR) * (255 - overlayR)) / 255; + g = baseG < 128 ? (2 * baseG * overlayG) / 255 : 255 - (2 * (255 - baseG) * (255 - overlayG)) / 255; + b = baseB < 128 ? (2 * baseB * overlayB) / 255 : 255 - (2 * (255 - baseB) * (255 - overlayB)) / 255; + break; + case 'multiply': + r = (baseR * overlayR) / 255; + g = (baseG * overlayG) / 255; + b = (baseB * overlayB) / 255; + break; + case 'screen': + r = 255 - ((255 - baseR) * (255 - overlayR)) / 255; + g = 255 - ((255 - baseG) * (255 - overlayG)) / 255; + b = 255 - ((255 - baseB) * (255 - overlayB)) / 255; + break; + default: // 'normal' + r = overlayR; + g = overlayG; + b = overlayB; + } + + // Alpha compositing + const alpha = overlayA / 255; + const outR = Math.round(r * alpha + baseR * (1 - alpha)); + const outG = Math.round(g * alpha + baseG * (1 - alpha)); + const outB = Math.round(b * alpha + baseB * (1 - alpha)); + const outA = Math.round(baseA + overlayA * (1 - baseA / 255)); + + return [ + Math.max(0, Math.min(255, outR)), + Math.max(0, Math.min(255, outG)), + Math.max(0, Math.min(255, outB)), + Math.max(0, Math.min(255, outA)) + ]; +} + +/** + * Composite two image data layers + */ +function compositeImageData( + baseImageData: ImageData, + overlayImageData: ImageData, + blendMode: 'normal' | 'overlay' | 'multiply' | 'screen', + overlayOpacity: number, + width: number, + height: number +): ImageData { + const result = new ImageData(width, height); + const baseData = baseImageData.data; + const overlayData = overlayImageData.data; + const resultData = result.data; + + // Handle tiled overlay (noise pattern) + const overlayWidth = overlayImageData.width; + const overlayHeight = overlayImageData.height; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const i = (y * width + x) * 4; + + // Tile the overlay + const overlayX = x % overlayWidth; + const overlayY = y % overlayHeight; + const overlayI = (overlayY * overlayWidth + overlayX) * 4; + + const [r, g, b, a] = blendPixel( + baseData[i], baseData[i + 1], baseData[i + 2], baseData[i + 3], + overlayData[overlayI], overlayData[overlayI + 1], overlayData[overlayI + 2], overlayData[overlayI + 3], + blendMode, + overlayOpacity + ); + + resultData[i] = r; + resultData[i + 1] = g; + resultData[i + 2] = b; + resultData[i + 3] = a; + } + } + + return result; +} + +/** + * Convert ImageData to a specific format using OffscreenCanvas + */ +async function convertFormat( + imageData: ImageData, + format: 'png' | 'jpeg' | 'webp', + quality: number, + width: number, + height: number +): Promise { + // Check if OffscreenCanvas is available + if (typeof OffscreenCanvas === 'undefined') { + throw new Error('OffscreenCanvas not available in this environment'); + } + + const canvas = new OffscreenCanvas(width, height); + const canvasCtx = canvas.getContext('2d'); + + if (!canvasCtx) { + throw new Error('Failed to get 2D context from OffscreenCanvas'); + } + + // Put the image data onto the canvas + canvasCtx.putImageData(imageData, 0, 0); + + // Determine MIME type + const mimeType = format === 'png' ? 'image/png' : + format === 'webp' ? 'image/webp' : 'image/jpeg'; + + // Convert to blob with quality setting + const blob = await canvas.convertToBlob({ + type: mimeType, + quality: format === 'png' ? undefined : quality, + }); + + // Convert blob to ArrayBuffer for transfer + const arrayBuffer = await blob.arrayBuffer(); + + return { + blob: arrayBuffer, + mimeType, + fileSize: arrayBuffer.byteLength, + }; +} + +/** + * Handle incoming messages + */ +ctx.onmessage = async (event: MessageEvent) => { + const { id, type, payload } = event.data; + + try { + let result: ImageData | ConvertFormatResult | undefined; + + switch (type) { + case 'generateNoise': { + const { width, height, intensity } = payload as NoisePayload; + result = generateNoiseTexture(width, height, intensity); + break; + } + + case 'applyBlur': { + const { imageData, blurAmount, width, height } = payload as BlurPayload; + result = applyBlur(imageData, blurAmount, width, height); + break; + } + + case 'applyOpacity': { + const { imageData, opacity, width, height } = payload as OpacityPayload; + result = applyOpacity(imageData, opacity, width, height); + break; + } + + case 'composite': { + const { baseImageData, overlayImageData, blendMode, overlayOpacity, width, height } = payload as CompositePayload; + result = compositeImageData(baseImageData, overlayImageData, blendMode, overlayOpacity, width, height); + break; + } + + case 'convertFormat': { + const { imageData, format, quality, width, height } = payload as ConvertFormatPayload; + // Reconstruct ImageData from transferred data + let imgData: ImageData; + if (imageData instanceof ImageData) { + imgData = imageData; + } else { + const { data, width: w, height: h } = imageData as unknown as { data: Uint8ClampedArray; width: number; height: number }; + imgData = new ImageData(new Uint8ClampedArray(data), w, h); + } + result = await convertFormat(imgData, format, quality, width, height); + break; + } + + default: + throw new Error(`Unknown message type: ${type}`); + } + + // Send result back to main thread + const response: ExportWorkerResponse = { + id, + type, + success: true, + result + }; + + // Transfer buffers for performance + if (result instanceof ImageData) { + ctx.postMessage(response, { transfer: [result.data.buffer] }); + } else if (result && 'blob' in result) { + // Transfer ArrayBuffer for convertFormat result + ctx.postMessage(response, { transfer: [result.blob] }); + } else { + ctx.postMessage(response); + } + } catch (error) { + const response: ExportWorkerResponse = { + id, + type, + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + ctx.postMessage(response); + } +}; + +// Signal that worker is ready +ctx.postMessage({ type: 'ready' }); + +// Export empty object for TypeScript module +export {}; diff --git a/apps/dashboard/lib/workers/index.ts b/apps/dashboard/lib/workers/index.ts new file mode 100644 index 0000000..2ca50f1 --- /dev/null +++ b/apps/dashboard/lib/workers/index.ts @@ -0,0 +1,13 @@ +/** + * Export workers module + * + * Re-exports the worker service for easy consumption + */ + +export { exportWorkerService, ExportWorkerService } from './export-worker-service'; +export type { + NoisePayload, + BlurPayload, + OpacityPayload, + CompositePayload +} from './export-worker-service'; diff --git a/apps/dashboard/next.config.ts b/apps/dashboard/next.config.ts index 5f2ec94..f61540d 100644 --- a/apps/dashboard/next.config.ts +++ b/apps/dashboard/next.config.ts @@ -5,6 +5,11 @@ const nextConfig: NextConfig = { reactCompiler: true, transpilePackages: ["@workspace/ui"], typedRoutes: true, + cacheComponents: true, + partialPrefetching: true, + experimental: { + useOffline: true, + }, }; export default nextConfig; diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 8d93f14..b01d601 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -13,26 +13,31 @@ "dependencies": { "@castfy/ui": "workspace:*", "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-slider": "^1.4.7", "@tanstack/react-form": "^1.32.0", "@tanstack/react-table": "^8.21.3", "cmdk": "^1.1.1", "geist": "^1.7.0", + "hugeicons-react": "^0.4.0", "lucide-react": "^1.16.0", "motion": "^12.38.0", - "next": "16.2.10", + "next": "16.3.0", "next-themes": "^0.4.6", "nuqs": "^2.9.3", - "react": "19.2.7", - "react-dom": "19.2.7", + "react": "19.2.8", + "react-dom": "19.2.8", + "react-dropzone": "^20.0.0", "react-email": "^6.6.0", "react-hook-form": "^7.76.0", "react-icons": "^5.6.0", + "react-moveable": "^0.56.0", "resend": "^6.12.3", "shadcn": "^4.7.0", "sonner": "^2.0.7", "tw-animate-css": "^1.4.0", "vaul": "^1.1.2", "zod": "^4.4.3", + "zundo": "^2.3.0", "zustand": "^5.0.14" }, "devDependencies": { diff --git a/apps/dashboard/types/mockup.ts b/apps/dashboard/types/mockup.ts new file mode 100644 index 0000000..35f04c7 --- /dev/null +++ b/apps/dashboard/types/mockup.ts @@ -0,0 +1,36 @@ +export type MockupType = "iphone" | "macbook" | "imac" | "iwatch"; + +export interface MockupScreenArea { + borderRadius?: number; + height: number; + notch?: { + x: number; + y: number; + width: number; + height: number; + borderRadius?: number; + }; + width: number; + x: number; + y: number; +} + +export interface MockupDefinition { + id: string; + name: string; + preview?: string; + screenArea: MockupScreenArea; + src: string; + type: MockupType; +} + +export interface Mockup { + definitionId: string; + id: string; + imageFit: "cover" | "contain" | "fill"; + isVisible: boolean; + opacity: number; + position: { x: number; y: number }; + rotation: number; + size: number; +} diff --git a/biome.jsonc b/biome.jsonc index 0aaa366..8a81bdb 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -8,6 +8,9 @@ "suspicious": { "noUnknownAtRules": "off", "noArrayIndexKey": "off" + }, + "style": { + "noNestedTernary": "off" } }, "domains": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5600589..cacb89a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,70 +31,85 @@ importers: version: link:../../packages/ui '@hookform/resolvers': specifier: ^5.2.2 - version: 5.2.2(react-hook-form@7.76.0(react@19.2.7)) + version: 5.2.2(react-hook-form@7.76.0(react@19.2.8)) + '@radix-ui/react-slider': + specifier: ^1.4.7 + version: 1.4.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@tanstack/react-form': specifier: ^1.32.0 - version: 1.32.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 1.32.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@tanstack/react-table': specifier: ^8.21.3 - version: 8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 8.21.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) cmdk: specifier: ^1.1.1 - version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) geist: specifier: ^1.7.0 - version: 1.7.0(next@16.2.10(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)) + version: 1.7.0(next@16.3.0(@babel/core@7.29.0)(@types/node@20.19.41)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)) + hugeicons-react: + specifier: ^0.4.0 + version: 0.4.0(react@19.2.8) lucide-react: specifier: ^1.16.0 - version: 1.16.0(react@19.2.7) + version: 1.16.0(react@19.2.8) motion: specifier: ^12.38.0 - version: 12.38.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 12.38.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) next: - specifier: 16.2.10 - version: 16.2.10(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + specifier: 16.3.0 + version: 16.3.0(@babel/core@7.29.0)(@types/node@20.19.41)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) next-themes: specifier: ^0.4.6 - version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) nuqs: specifier: ^2.9.3 - version: 2.9.3(next@16.2.10(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 2.9.3(next@16.3.0(@babel/core@7.29.0)(@types/node@20.19.41)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) react: - specifier: 19.2.7 - version: 19.2.7 + specifier: 19.2.8 + version: 19.2.8 react-dom: - specifier: 19.2.7 - version: 19.2.7(react@19.2.7) + specifier: 19.2.8 + version: 19.2.8(react@19.2.8) + react-dropzone: + specifier: ^20.0.0 + version: 20.0.0(@types/react@19.2.14)(react@19.2.8) react-email: specifier: ^6.6.0 - version: 6.6.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 6.6.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react-hook-form: specifier: ^7.76.0 - version: 7.76.0(react@19.2.7) + version: 7.76.0(react@19.2.8) react-icons: specifier: ^5.6.0 - version: 5.6.0(react@19.2.7) + version: 5.6.0(react@19.2.8) + react-moveable: + specifier: ^0.56.0 + version: 0.56.0 resend: specifier: ^6.12.3 - version: 6.12.3(@react-email/render@2.0.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)) + version: 6.12.3(@react-email/render@2.0.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8)) shadcn: specifier: ^4.7.0 version: 4.7.0(@types/node@20.19.41)(typescript@5.9.3) sonner: specifier: ^2.0.7 - version: 2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 2.0.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8) tw-animate-css: specifier: ^1.4.0 version: 1.4.0 vaul: specifier: ^1.1.2 - version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) zod: specifier: ^4.4.3 version: 4.4.3 + zundo: + specifier: ^2.3.0 + version: 2.3.0(zustand@5.0.14(@types/react@19.2.14)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8))) zustand: specifier: ^5.0.14 - version: 5.0.14(@types/react@19.2.14)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) + version: 5.0.14(@types/react@19.2.14)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)) devDependencies: '@biomejs/biome': specifier: 2.4.16 @@ -659,6 +674,9 @@ packages: cpu: [x64] os: [win32] + '@cfcs/core@0.0.6': + resolution: {integrity: sha512-FxfJMwoLB8MEMConeXUCqtMGqxdtePQxRBOiGip9ULcYYam3WfCgoY6xdnMaSkYvRvmosp5iuG+TiPofm65+Pw==} + '@clack/core@1.4.1': resolution: {integrity: sha512-FILJa1gGKEFTGZAJE9RpVhrjKz3c3h4ar60dSv6cGuDqufQ84YEIS3GAGvZiN+H6yaLbbvTFNejjCC4tXpZEuw==} engines: {node: '>= 20.12.0'} @@ -667,6 +685,9 @@ packages: resolution: {integrity: sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw==} engines: {node: '>= 20.12.0'} + '@daybrush/utils@1.13.0': + resolution: {integrity: sha512-ALK12C6SQNNHw1enXK+UO8bdyQ+jaWNQ1Af7Z3FNxeAwjYhQT7do+TRE4RASAJ3ObaS2+TJ7TXR3oz2Gzbw0PQ==} + '@derhuerst/http-basic@8.2.4': resolution: {integrity: sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==} engines: {node: '>=6.0.0'} @@ -681,6 +702,21 @@ packages: peerDependencies: '@noble/ciphers': ^1.0.0 + '@egjs/agent@2.4.4': + resolution: {integrity: sha512-cvAPSlUILhBBOakn2krdPnOGv5hAZq92f1YHxYcfu0p7uarix2C6Ia3AVizpS1SGRZGiEkIS5E+IVTLg1I2Iog==} + + '@egjs/children-differ@1.0.1': + resolution: {integrity: sha512-DRvyqMf+CPCOzAopQKHtW+X8iN6Hy6SFol+/7zCUiE5y4P/OB8JP8FtU4NxtZwtafvSL4faD5KoQYPj3JHzPFQ==} + + '@egjs/component@3.0.5': + resolution: {integrity: sha512-cLcGizTrrUNA2EYE3MBmEDt2tQv1joVP1Q3oDisZ5nw0MZDx2kcgEXM+/kZpfa/PAkFvYVhRUZwytIQWoN3V/w==} + + '@egjs/list-differ@1.0.1': + resolution: {integrity: sha512-OTFTDQcWS+1ZREOdCWuk5hCBgYO4OsD30lXcOCyVOAjXMhgL5rBRDnt/otb6Nz8CzU0L/igdcaQBDLWc4t9gvg==} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@emnapi/runtime@1.7.1': resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} @@ -1069,6 +1105,14 @@ packages: peerDependencies: react-hook-form: ^7.55.0 + '@hugeicons/core-free-icons@3.3.0': + resolution: {integrity: sha512-qYyr4JQ2eQIHTSTbITvnJvs6ERNK64D9gpwZnf2IyuG0exzqfyABLO/oTB71FB3RZPfu1GbwycdiGSo46apjMQ==} + + '@hugeicons/react@1.1.9': + resolution: {integrity: sha512-O+lWSWjbijoAvMCxn4K2bQWCGN5+mP1y5j+X99j23mXMj+s0X25fs71T6t9YJLaBodwmZdaewD27dzS/PiboQw==} + peerDependencies: + react: '>=16.0.0' + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1089,139 +1133,285 @@ packages: resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} engines: {node: '>=18'} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + '@img/sharp-darwin-arm64@0.34.5': resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [darwin] + '@img/sharp-darwin-arm64@0.35.3': + resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + '@img/sharp-darwin-x64@0.34.5': resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [darwin] + '@img/sharp-darwin-x64@0.35.3': + resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.3': + resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + engines: {node: '>=20.9.0'} + os: [freebsd] + '@img/sharp-libvips-darwin-arm64@1.2.4': resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} cpu: [arm64] os: [darwin] + '@img/sharp-libvips-darwin-arm64@1.3.2': + resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} + cpu: [arm64] + os: [darwin] + '@img/sharp-libvips-darwin-x64@1.2.4': resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} cpu: [x64] os: [darwin] + '@img/sharp-libvips-darwin-x64@1.3.2': + resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} + cpu: [x64] + os: [darwin] + '@img/sharp-libvips-linux-arm64@1.2.4': resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + '@img/sharp-libvips-linux-arm64@1.3.2': + resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} + cpu: [arm64] + os: [linux] + '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + '@img/sharp-libvips-linux-arm@1.3.2': + resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} + cpu: [arm] + os: [linux] + '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + '@img/sharp-libvips-linux-ppc64@1.3.2': + resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} + cpu: [ppc64] + os: [linux] + '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + '@img/sharp-libvips-linux-riscv64@1.3.2': + resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} + cpu: [riscv64] + os: [linux] + '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + '@img/sharp-libvips-linux-s390x@1.3.2': + resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} + cpu: [s390x] + os: [linux] + '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + '@img/sharp-libvips-linux-x64@1.3.2': + resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} + cpu: [x64] + os: [linux] + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} + cpu: [arm64] + os: [linux] + '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} + cpu: [x64] + os: [linux] + '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + '@img/sharp-linux-arm64@0.35.3': + resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + '@img/sharp-linux-arm@0.35.3': + resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + '@img/sharp-linux-ppc64@0.35.3': + resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + '@img/sharp-linux-riscv64@0.35.3': + resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + '@img/sharp-linux-s390x@0.35.3': + resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + '@img/sharp-linux-x64@0.35.3': + resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + '@img/sharp-linuxmusl-arm64@0.35.3': + resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + '@img/sharp-linuxmusl-x64@0.35.3': + resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [wasm32] + '@img/sharp-wasm32@0.35.3': + resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.3': + resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + '@img/sharp-win32-arm64@0.34.5': resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [win32] + '@img/sharp-win32-arm64@0.35.3': + resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + '@img/sharp-win32-ia32@0.34.5': resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ia32] os: [win32] + '@img/sharp-win32-ia32@0.35.3': + resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + '@img/sharp-win32-x64@0.34.5': resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [win32] + '@img/sharp-win32-x64@0.35.3': + resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@inquirer/ansi@1.0.2': resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} engines: {node: '>=18'} @@ -1424,54 +1614,105 @@ packages: '@next/env@16.2.10': resolution: {integrity: sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==} + '@next/env@16.3.0': + resolution: {integrity: sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==} + '@next/swc-darwin-arm64@16.2.10': resolution: {integrity: sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] + '@next/swc-darwin-arm64@16.3.0': + resolution: {integrity: sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + '@next/swc-darwin-x64@16.2.10': resolution: {integrity: sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] + '@next/swc-darwin-x64@16.3.0': + resolution: {integrity: sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + '@next/swc-linux-arm64-gnu@16.2.10': resolution: {integrity: sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + '@next/swc-linux-arm64-gnu@16.3.0': + resolution: {integrity: sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + '@next/swc-linux-arm64-musl@16.2.10': resolution: {integrity: sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + '@next/swc-linux-arm64-musl@16.3.0': + resolution: {integrity: sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + '@next/swc-linux-x64-gnu@16.2.10': resolution: {integrity: sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + '@next/swc-linux-x64-gnu@16.3.0': + resolution: {integrity: sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + '@next/swc-linux-x64-musl@16.2.10': resolution: {integrity: sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + '@next/swc-linux-x64-musl@16.3.0': + resolution: {integrity: sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + '@next/swc-win32-arm64-msvc@16.2.10': resolution: {integrity: sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] + '@next/swc-win32-arm64-msvc@16.3.0': + resolution: {integrity: sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + '@next/swc-win32-x64-msvc@16.2.10': resolution: {integrity: sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==} engines: {node: '>= 10'} cpu: [x64] os: [win32] + '@next/swc-win32-x64-msvc@16.3.0': + resolution: {integrity: sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + '@noble/ciphers@1.3.0': resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} @@ -1560,9 +1801,15 @@ packages: '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + '@radix-ui/number@1.1.3': + resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==} + '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + '@radix-ui/primitive@1.1.7': + resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} + '@radix-ui/react-accessible-icon@1.1.7': resolution: {integrity: sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==} peerDependencies: @@ -1667,6 +1914,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-collection@1.1.15': + resolution: {integrity: sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==} + 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 + '@radix-ui/react-collection@1.1.7': resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} peerDependencies: @@ -1689,6 +1949,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-compose-refs@1.1.5': + resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-context-menu@2.2.16': resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==} peerDependencies: @@ -1711,6 +1980,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.2.2': + resolution: {integrity: sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dialog@1.1.15': resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} peerDependencies: @@ -1733,6 +2011,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-direction@1.1.4': + resolution: {integrity: sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dismissable-layer@1.1.11': resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} peerDependencies: @@ -1946,6 +2233,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.1.10': + resolution: {integrity: sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==} + 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 + '@radix-ui/react-primitive@2.1.3': resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} peerDependencies: @@ -2050,6 +2350,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-slider@1.4.7': + resolution: {integrity: sha512-mTSLf1GC/C0moWjTbvCM6Qn/gBjvlFt1azuWF2v7MN5C3Zq2U2J2lN3ZEYkpujuOU5Ro7A28wkviSxaKnG0BYg==} + 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 + '@radix-ui/react-slot@1.2.3': resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} peerDependencies: @@ -2059,6 +2372,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.3.3': + resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-switch@1.2.6': resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} peerDependencies: @@ -2168,6 +2490,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-controllable-state@1.2.6': + resolution: {integrity: sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-effect-event@0.0.2': resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} peerDependencies: @@ -2177,6 +2508,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-effect-event@0.0.5': + resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-escape-keydown@1.1.1': resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} peerDependencies: @@ -2204,6 +2544,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-layout-effect@1.1.4': + resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-previous@1.1.1': resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} peerDependencies: @@ -2213,6 +2562,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-previous@1.1.4': + resolution: {integrity: sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-rect@1.1.1': resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} peerDependencies: @@ -2231,6 +2589,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-size@1.1.4': + resolution: {integrity: sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-visually-hidden@1.2.3': resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} peerDependencies: @@ -2254,6 +2621,15 @@ packages: react: ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^18.0 || ^19.0 || ^19.0.0-rc + '@scena/dragscroll@1.4.0': + resolution: {integrity: sha512-3O8daaZD9VXA9CP3dra6xcgt/qrm0mg0xJCwiX6druCteQ9FFsXffkF8PrqxY4Z4VJ58fFKEa0RlKqbsi/XnRA==} + + '@scena/event-emitter@1.0.5': + resolution: {integrity: sha512-AzY4OTb0+7ynefmWFQ6hxDdk0CySAq/D4efljfhtRHCOP7MBF9zUfhKG3TJiroVjASqVgkRJFdenS8ArZo6Olg==} + + '@scena/matrix@1.1.1': + resolution: {integrity: sha512-JVKBhN0tm2Srl+Yt+Ywqu0oLgLcdemDQlD1OxmN9jaCTwaFPZ7tY8n6dhVgMEaR9qcR7r+kAlMXnSfNyYdE+Vg==} + '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -2663,6 +3039,10 @@ packages: atomically@2.1.1: resolution: {integrity: sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==} + attr-accept@2.2.5: + resolution: {integrity: sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==} + engines: {node: '>=4'} + babel-plugin-react-compiler@1.0.0: resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} @@ -2948,6 +3328,12 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-styled@1.0.8: + resolution: {integrity: sha512-tCpP7kLRI8dI95rCh3Syl7I+v7PP+2JYOzWkl0bUEoSbJM+u8ITbutjlQVf0NC2/g4ULROJPi16sfwDIO8/84g==} + + css-to-mat@1.1.1: + resolution: {integrity: sha512-kvpxFYZb27jRd2vium35G7q5XZ2WJ9rWjDUMNT36M3Hc41qCrLXFM5iEKMGXcrPsKfXEN+8l/riB4QzwwwiEyQ==} + css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} @@ -3361,6 +3747,10 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + file-selector@4.1.0: + resolution: {integrity: sha512-Io1mP8CI3zec5Bxy3P3TxdrKnt35Cm8vNIHnZsvyj43l4YFjD4NRInBp240S5bDJQ0EP1jnh7nCAwXsO818OCg==} + engines: {node: '>= 20'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -3417,6 +3807,9 @@ packages: react-dom: optional: true + framework-utils@1.1.0: + resolution: {integrity: sha512-KAfqli5PwpFJ8o3psRNs8svpMGyCSAe8nmGcjQ0zZBWN2H6dZDnq+ABp3N3hdUmFeMrLtjOCTXD4yplUJIWceg==} + fresh@0.5.2: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} @@ -3469,6 +3862,9 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + gesto@1.19.4: + resolution: {integrity: sha512-hfr/0dWwh0Bnbb88s3QVJd1ZRJeOWcgHPPwmiH6NnafDYvhTsxg+SLYu+q/oPNh9JS3V+nlr6fNs8kvPAtcRDQ==} + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -3620,6 +4016,11 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + hugeicons-react@0.4.0: + resolution: {integrity: sha512-HA2UI3VkDd1dNi9P4JGjaMODdGEs4QVMwQcc34W0aoGHptdsIex/756Jik4GFYN/023jGFKNVvQKhx2IpZtV+g==} + peerDependencies: + react: '>=16.0.0' + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -3863,6 +4264,12 @@ packages: jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keycode@2.2.1: + resolution: {integrity: sha512-Rdgz9Hl9Iv4QKi8b0OlCRQEzp4AgVxyCtz5S/+VIHezDmrDhkp2N2TqBWOLz0/gbeREXOOiI9/4b8BY9uw2vFg==} + + keycon@1.4.0: + resolution: {integrity: sha512-p1NAIxiRMH3jYfTeXRs2uWbVJ1WpEjpi8ktzUyBJsX7/wn2qu2VRXktneBLNtKNxJmlUYxRi9gOJt1DuthXR7A==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -4153,6 +4560,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.17: + resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -4198,6 +4610,27 @@ packages: sass: optional: true + next@16.3.0: + resolution: {integrity: sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + no-case@2.3.2: resolution: {integrity: sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==} @@ -4326,6 +4759,9 @@ packages: outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + overlap-area@1.1.0: + resolution: {integrity: sha512-3dlJgJCaVeXH0/eZjYVJvQiLVVrPO4U1ZGqlATtx6QGO3b5eNM6+JgUKa7oStBTdYuGTk7gVoABCW6Tp+dhRdw==} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -4469,6 +4905,10 @@ packages: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.23: + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} + engines: {node: ^10 || ^12 || >=14} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -4571,6 +5011,9 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true + react-css-styled@1.1.9: + resolution: {integrity: sha512-M7fJZ3IWFaIHcZEkoFOnkjdiUFmwd8d+gTh2bpqMOcnxy/0Gsykw4dsL4QBiKsxcGow6tETUa4NAUcmJF+/nfw==} + react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: @@ -4581,6 +5024,21 @@ packages: peerDependencies: react: ^19.2.7 + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-dropzone@20.0.0: + resolution: {integrity: sha512-Xw8tvvVPJQzj8ir5wivUMzA+G6R+aGhdU5KQzUMvVBlJNb26AW/0137VoYVmb5UgZcbhM9OCpjE4KOqqSL9QuQ==} + engines: {node: '>= 22'} + peerDependencies: + '@types/react': '*' + react: '>= 18' + peerDependenciesMeta: + '@types/react': + optional: true + react-email@6.6.0: resolution: {integrity: sha512-YNOLCMcGqwcvRTzFo2qMVD5D8Ro9aw0Y+PxO1LhqQT+qbkRvdUxmrVXffUxEcDeIFAcoOe7luCbkwIZPpPDFxQ==} engines: {node: '>=20.0.0'} @@ -4600,6 +5058,9 @@ packages: peerDependencies: react: '*' + react-moveable@0.56.0: + resolution: {integrity: sha512-FmJNmIOsOA36mdxbrc/huiE4wuXSRlmon/o+/OrfNhSiYYYL0AV5oObtPluEhb2Yr/7EfYWBHTxF5aWAvjg1SA==} + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -4620,6 +5081,9 @@ packages: '@types/react': optional: true + react-selecto@1.26.3: + resolution: {integrity: sha512-Ubik7kWSnZyQEBNro+1k38hZaI1tJarE+5aD/qsqCOA1uUBSjgKVBy3EWRzGIbdmVex7DcxznFZLec/6KZNvwQ==} + react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -4647,6 +5111,10 @@ packages: resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -4753,6 +5221,9 @@ packages: selderee@0.11.0: resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} + selecto@1.26.3: + resolution: {integrity: sha512-gZHgqMy5uyB6/2YDjv3Qqaf7bd2hTDOpPdxXlrez4R3/L0GiEWDCFaUfrflomgqdb3SxHF2IXY0Jw0EamZi7cw==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -4767,6 +5238,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + send@0.19.2: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} @@ -4800,6 +5276,15 @@ packages: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.35.3: + resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -5320,6 +5805,11 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zundo@2.3.0: + resolution: {integrity: sha512-4GXYxXA17SIKYhVbWHdSEU04P697IMyVGXrC2TnzoyohEAWytFNOKqOp5gTGvaW93F/PM5Y0evbGtOPF0PWQwQ==} + peerDependencies: + zustand: ^4.3.0 || ^5.0.0 + zustand@5.0.14: resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} engines: {node: '>=12.20.0'} @@ -5628,6 +6118,10 @@ snapshots: '@biomejs/cli-win32-x64@2.4.16': optional: true + '@cfcs/core@0.0.6': + dependencies: + '@egjs/component': 3.0.5 + '@clack/core@1.4.1': dependencies: fast-wrap-ansi: 0.2.0 @@ -5640,6 +6134,8 @@ snapshots: fast-wrap-ansi: 0.2.0 sisteransi: 1.0.5 + '@daybrush/utils@1.13.0': {} + '@derhuerst/http-basic@8.2.4': dependencies: caseless: 0.12.0 @@ -5664,6 +6160,21 @@ snapshots: dependencies: '@noble/ciphers': 1.3.0 + '@egjs/agent@2.4.4': {} + + '@egjs/children-differ@1.0.1': + dependencies: + '@egjs/list-differ': 1.0.1 + + '@egjs/component@3.0.5': {} + + '@egjs/list-differ@1.0.1': {} + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.7.1': dependencies: tslib: 2.8.1 @@ -5910,6 +6421,17 @@ snapshots: '@standard-schema/utils': 0.3.0 react-hook-form: 7.76.0(react@19.2.7) + '@hookform/resolvers@5.2.2(react-hook-form@7.76.0(react@19.2.8))': + dependencies: + '@standard-schema/utils': 0.3.0 + react-hook-form: 7.76.0(react@19.2.8) + + '@hugeicons/core-free-icons@3.3.0': {} + + '@hugeicons/react@1.1.9(react@19.2.8)': + dependencies: + react: 19.2.8 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -5924,79 +6446,162 @@ snapshots: '@img/colour@1.0.0': optional: true + '@img/colour@1.1.0': + optional: true + '@img/sharp-darwin-arm64@0.34.5': optionalDependencies: '@img/sharp-libvips-darwin-arm64': 1.2.4 optional: true + '@img/sharp-darwin-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.2 + optional: true + '@img/sharp-darwin-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-darwin-x64': 1.2.4 optional: true + '@img/sharp-darwin-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.2 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + '@img/sharp-libvips-darwin-arm64@1.2.4': optional: true + '@img/sharp-libvips-darwin-arm64@1.3.2': + optional: true + '@img/sharp-libvips-darwin-x64@1.2.4': optional: true + '@img/sharp-libvips-darwin-x64@1.3.2': + optional: true + '@img/sharp-libvips-linux-arm64@1.2.4': optional: true + '@img/sharp-libvips-linux-arm64@1.3.2': + optional: true + '@img/sharp-libvips-linux-arm@1.2.4': optional: true + '@img/sharp-libvips-linux-arm@1.3.2': + optional: true + '@img/sharp-libvips-linux-ppc64@1.2.4': optional: true + '@img/sharp-libvips-linux-ppc64@1.3.2': + optional: true + '@img/sharp-libvips-linux-riscv64@1.2.4': optional: true + '@img/sharp-libvips-linux-riscv64@1.3.2': + optional: true + '@img/sharp-libvips-linux-s390x@1.2.4': optional: true + '@img/sharp-libvips-linux-s390x@1.3.2': + optional: true + '@img/sharp-libvips-linux-x64@1.2.4': optional: true + '@img/sharp-libvips-linux-x64@1.3.2': + optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + optional: true + '@img/sharp-libvips-linuxmusl-x64@1.2.4': optional: true + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + optional: true + '@img/sharp-linux-arm64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-arm64': 1.2.4 optional: true + '@img/sharp-linux-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.2 + optional: true + '@img/sharp-linux-arm@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-arm': 1.2.4 optional: true + '@img/sharp-linux-arm@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.2 + optional: true + '@img/sharp-linux-ppc64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-ppc64': 1.2.4 optional: true + '@img/sharp-linux-ppc64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.2 + optional: true + '@img/sharp-linux-riscv64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-riscv64': 1.2.4 optional: true + '@img/sharp-linux-riscv64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.2 + optional: true + '@img/sharp-linux-s390x@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-s390x': 1.2.4 optional: true + '@img/sharp-linux-s390x@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.2 + optional: true + '@img/sharp-linux-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-x64': 1.2.4 optional: true + '@img/sharp-linux-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.2 + optional: true + '@img/sharp-linuxmusl-arm64@0.34.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 optional: true '@img/sharp-linuxmusl-x64@0.34.5': @@ -6004,20 +6609,44 @@ snapshots: '@img/sharp-libvips-linuxmusl-x64': 1.2.4 optional: true + '@img/sharp-linuxmusl-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + optional: true + '@img/sharp-wasm32@0.34.5': dependencies: '@emnapi/runtime': 1.7.1 optional: true + '@img/sharp-wasm32@0.35.3': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + '@img/sharp-win32-arm64@0.34.5': optional: true + '@img/sharp-win32-arm64@0.35.3': + optional: true + '@img/sharp-win32-ia32@0.34.5': optional: true + '@img/sharp-win32-ia32@0.35.3': + optional: true + '@img/sharp-win32-x64@0.34.5': optional: true + '@img/sharp-win32-x64@0.35.3': + optional: true + '@inquirer/ansi@1.0.2': {} '@inquirer/ansi@2.0.5': {} @@ -6245,30 +6874,56 @@ snapshots: '@next/env@16.2.10': {} + '@next/env@16.3.0': {} + '@next/swc-darwin-arm64@16.2.10': optional: true + '@next/swc-darwin-arm64@16.3.0': + optional: true + '@next/swc-darwin-x64@16.2.10': optional: true + '@next/swc-darwin-x64@16.3.0': + optional: true + '@next/swc-linux-arm64-gnu@16.2.10': optional: true + '@next/swc-linux-arm64-gnu@16.3.0': + optional: true + '@next/swc-linux-arm64-musl@16.2.10': optional: true + '@next/swc-linux-arm64-musl@16.3.0': + optional: true + '@next/swc-linux-x64-gnu@16.2.10': optional: true + '@next/swc-linux-x64-gnu@16.3.0': + optional: true + '@next/swc-linux-x64-musl@16.2.10': optional: true + '@next/swc-linux-x64-musl@16.3.0': + optional: true + '@next/swc-win32-arm64-msvc@16.2.10': optional: true + '@next/swc-win32-arm64-msvc@16.3.0': + optional: true + '@next/swc-win32-x64-msvc@16.2.10': optional: true + '@next/swc-win32-x64-msvc@16.3.0': + optional: true + '@noble/ciphers@1.3.0': {} '@noble/curves@1.9.7': @@ -6342,8 +6997,12 @@ snapshots: '@radix-ui/number@1.1.1': {} + '@radix-ui/number@1.1.3': {} + '@radix-ui/primitive@1.1.3': {} + '@radix-ui/primitive@1.1.7': {} + '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -6447,6 +7106,18 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-collection@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.14)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) @@ -6465,9 +7136,15 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.7)': + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.8)': dependencies: - react: 19.2.7 + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.14)(react@19.2.8)': + dependencies: + react: 19.2.8 optionalDependencies: '@types/react': 19.2.14 @@ -6491,9 +7168,15 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.7)': + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.8)': dependencies: - react: 19.2.7 + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-context@1.2.2(@types/react@19.2.14)(react@19.2.8)': + dependencies: + react: 19.2.8 optionalDependencies: '@types/react': 19.2.14 @@ -6519,24 +7202,24 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.8) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.8) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.8) aria-hidden: 1.2.6 - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.7) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.8) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) @@ -6547,6 +7230,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-direction@1.1.4(@types/react@19.2.14)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -6560,15 +7249,15 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.7) - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.8) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) @@ -6594,9 +7283,9 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.7)': + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.8)': dependencies: - react: 19.2.7 + react: 19.2.8 optionalDependencies: '@types/react': 19.2.14 @@ -6611,13 +7300,13 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.7) - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) @@ -6660,10 +7349,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.7)': + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.8)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.7) - react: 19.2.7 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.8) + react: 19.2.8 optionalDependencies: '@types/react': 19.2.14 @@ -6829,12 +7518,12 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.7) - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) @@ -6849,12 +7538,21 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.7) - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-primitive@2.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.14)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) @@ -6868,11 +7566,11 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.7) - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) @@ -6996,6 +7694,25 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-slider@1.4.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.14)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.14)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.8) + '@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.14)(react@19.2.8) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.14)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) @@ -7003,10 +7720,17 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.7)': + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.8)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) - react: 19.2.7 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-slot@1.3.3(@types/react@19.2.14)(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.8) + react: 19.2.8 optionalDependencies: '@types/react': 19.2.14 @@ -7128,9 +7852,9 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.7)': + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.8)': dependencies: - react: 19.2.7 + react: 19.2.8 optionalDependencies: '@types/react': 19.2.14 @@ -7142,11 +7866,20 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.7)': + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.8)': dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.7) - react: 19.2.7 + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.14)(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.14)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.8) + react: 19.2.8 optionalDependencies: '@types/react': 19.2.14 @@ -7157,10 +7890,17 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.7)': + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.8)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.7) - react: 19.2.7 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.14)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.8) + react: 19.2.8 optionalDependencies: '@types/react': 19.2.14 @@ -7171,10 +7911,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.7)': + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.8)': dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.7) - react: 19.2.7 + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.8) + react: 19.2.8 optionalDependencies: '@types/react': 19.2.14 @@ -7191,9 +7931,15 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.7)': + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.8)': dependencies: - react: 19.2.7 + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.14)(react@19.2.8)': + dependencies: + react: 19.2.8 optionalDependencies: '@types/react': 19.2.14 @@ -7203,6 +7949,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-use-previous@1.1.4(@types/react@19.2.14)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.4)': dependencies: '@radix-ui/rect': 1.1.1 @@ -7217,6 +7969,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-use-size@1.1.4(@types/react@19.2.14)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -7235,6 +7994,26 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) + '@react-email/render@2.0.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + html-to-text: 9.0.5 + prettier: 3.8.4 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + '@scena/dragscroll@1.4.0': + dependencies: + '@daybrush/utils': 1.13.0 + '@scena/event-emitter': 1.0.5 + + '@scena/event-emitter@1.0.5': + dependencies: + '@daybrush/utils': 1.13.0 + + '@scena/matrix@1.1.1': + dependencies: + '@daybrush/utils': 1.13.0 + '@sec-ant/readable-stream@0.4.1': {} '@selderee/plugin-htmlparser2@0.11.0': @@ -7348,6 +8127,14 @@ snapshots: transitivePeerDependencies: - react-dom + '@tanstack/react-form@1.32.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@tanstack/form-core': 1.32.0 + '@tanstack/react-store': 0.9.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + transitivePeerDependencies: + - react-dom + '@tanstack/react-store@0.9.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@tanstack/store': 0.9.3 @@ -7355,11 +8142,18 @@ snapshots: react-dom: 19.2.7(react@19.2.7) use-sync-external-store: 1.6.0(react@19.2.7) - '@tanstack/react-table@8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@tanstack/react-store@0.9.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@tanstack/store': 0.9.3 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + use-sync-external-store: 1.6.0(react@19.2.8) + + '@tanstack/react-table@8.21.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@tanstack/table-core': 8.21.3 - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) '@tanstack/store@0.9.3': {} @@ -7645,6 +8439,8 @@ snapshots: stubborn-fs: 2.0.0 when-exit: 2.1.5 + attr-accept@2.2.5: {} + babel-plugin-react-compiler@1.0.0: dependencies: '@babel/types': 7.29.0 @@ -7835,14 +8631,14 @@ snapshots: clsx@2.1.1: {} - cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.8) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -7945,6 +8741,15 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-styled@1.0.8: + dependencies: + '@daybrush/utils': 1.13.0 + + css-to-mat@1.1.1: + dependencies: + '@daybrush/utils': 1.13.0 + '@scena/matrix': 1.1.1 + css-tree@3.2.1: dependencies: mdn-data: 2.27.1 @@ -8481,6 +9286,8 @@ snapshots: dependencies: flat-cache: 4.0.1 + file-selector@4.1.0: {} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -8550,6 +9357,17 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) + framer-motion@12.38.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + motion-dom: 12.38.0 + motion-utils: 12.36.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + framework-utils@1.1.0: {} + fresh@0.5.2: {} fresh@2.0.0: {} @@ -8598,8 +9416,17 @@ snapshots: dependencies: next: 16.2.10(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + geist@1.7.0(next@16.3.0(@babel/core@7.29.0)(@types/node@20.19.41)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)): + dependencies: + next: 16.3.0(@babel/core@7.29.0)(@types/node@20.19.41)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + gensync@1.0.0-beta.2: {} + gesto@1.19.4: + dependencies: + '@daybrush/utils': 1.13.0 + '@scena/event-emitter': 1.0.5 + get-caller-file@2.0.5: {} get-east-asian-width@1.6.0: {} @@ -8790,6 +9617,12 @@ snapshots: transitivePeerDependencies: - supports-color + hugeicons-react@0.4.0(react@19.2.8): + dependencies: + '@hugeicons/core-free-icons': 3.3.0 + '@hugeicons/react': 1.1.9(react@19.2.8) + react: 19.2.8 + human-signals@2.1.0: {} human-signals@8.0.1: {} @@ -8983,6 +9816,15 @@ snapshots: jwa: 2.0.1 safe-buffer: 5.2.1 + keycode@2.2.1: {} + + keycon@1.4.0: + dependencies: + '@cfcs/core': 0.0.6 + '@daybrush/utils': 1.13.0 + '@scena/event-emitter': 1.0.5 + keycode: 2.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -9097,6 +9939,10 @@ snapshots: dependencies: react: 19.2.7 + lucide-react@1.16.0(react@19.2.8): + dependencies: + react: 19.2.8 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -9178,6 +10024,14 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) + motion@12.38.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + framer-motion: 12.38.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + ms@2.0.0: {} ms@2.1.3: {} @@ -9240,6 +10094,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@3.3.17: {} + natural-compare@1.4.0: {} negotiator@0.6.3: {} @@ -9260,6 +10116,11 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) + next-themes@0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + next@16.2.10(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@next/env': 16.2.10 @@ -9285,6 +10146,32 @@ snapshots: - '@babel/core' - babel-plugin-macros + next@16.3.0(@babel/core@7.29.0)(@types/node@20.19.41)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + '@next/env': 16.3.0 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.9.19 + caniuse-lite: 1.0.30001760 + postcss: 8.5.23 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.8) + optionalDependencies: + '@next/swc-darwin-arm64': 16.3.0 + '@next/swc-darwin-x64': 16.3.0 + '@next/swc-linux-arm64-gnu': 16.3.0 + '@next/swc-linux-arm64-musl': 16.3.0 + '@next/swc-linux-x64-gnu': 16.3.0 + '@next/swc-linux-x64-musl': 16.3.0 + '@next/swc-win32-arm64-msvc': 16.3.0 + '@next/swc-win32-x64-msvc': 16.3.0 + babel-plugin-react-compiler: 1.0.0 + sharp: 0.35.3(@types/node@20.19.41) + transitivePeerDependencies: + - '@babel/core' + - '@types/node' + - babel-plugin-macros + no-case@2.3.2: dependencies: lower-case: 1.1.4 @@ -9328,12 +10215,12 @@ snapshots: path-key: 4.0.0 unicorn-magic: 0.3.0 - nuqs@2.9.3(next@16.2.10(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7): + nuqs@2.9.3(next@16.3.0(@babel/core@7.29.0)(@types/node@20.19.41)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): dependencies: '@standard-schema/spec': 1.1.0 - react: 19.2.7 + react: 19.2.8 optionalDependencies: - next: 16.2.10(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 16.3.0(@babel/core@7.29.0)(@types/node@20.19.41)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) nypm@0.6.6: dependencies: @@ -9413,6 +10300,10 @@ snapshots: outvariant@1.4.3: {} + overlap-area@1.1.0: + dependencies: + '@daybrush/utils': 1.13.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -9545,6 +10436,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.23: + dependencies: + nanoid: 3.3.17 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -9717,6 +10614,11 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 + react-css-styled@1.1.9: + dependencies: + css-styled: 1.0.8 + framework-utils: 1.1.0 + react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 @@ -9727,6 +10629,19 @@ snapshots: react: 19.2.7 scheduler: 0.27.0 + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-dropzone@20.0.0(@types/react@19.2.14)(react@19.2.8): + dependencies: + attr-accept: 2.2.5 + file-selector: 4.1.0 + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.14 + react-email@6.6.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@babel/parser': 7.27.0 @@ -9758,13 +10673,64 @@ snapshots: - supports-color - utf-8-validate + react-email@6.6.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + '@babel/parser': 7.27.0 + '@babel/traverse': 7.27.0 + '@react-email/render': 2.0.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + chokidar: 4.0.3 + commander: 13.1.0 + conf: 15.1.0 + css-tree: 3.2.1 + debounce: 2.2.0 + esbuild: 0.28.1 + glob: 13.0.6 + jiti: 2.4.2 + log-symbols: 7.0.1 + marked: 15.0.12 + mime-types: 3.0.2 + normalize-path: 3.0.0 + nypm: 0.6.6 + picospinner: 3.0.0 + prismjs: 1.30.0 + prompts: 2.4.2 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + socket.io: 4.8.3 + tailwindcss: 4.1.18 + tsconfig-paths: 4.2.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + react-hook-form@7.76.0(react@19.2.7): dependencies: react: 19.2.7 - react-icons@5.6.0(react@19.2.7): + react-hook-form@7.76.0(react@19.2.8): dependencies: - react: 19.2.7 + react: 19.2.8 + + react-icons@5.6.0(react@19.2.8): + dependencies: + react: 19.2.8 + + react-moveable@0.56.0: + dependencies: + '@daybrush/utils': 1.13.0 + '@egjs/agent': 2.4.4 + '@egjs/children-differ': 1.0.1 + '@egjs/list-differ': 1.0.1 + '@scena/dragscroll': 1.4.0 + '@scena/event-emitter': 1.0.5 + '@scena/matrix': 1.1.1 + css-to-mat: 1.1.1 + framework-utils: 1.1.0 + gesto: 1.19.4 + overlap-area: 1.1.0 + react-css-styled: 1.1.9 + react-selecto: 1.26.3 react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): dependencies: @@ -9774,10 +10740,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.7): + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.8): dependencies: - react: 19.2.7 - react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.7) + react: 19.2.8 + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.8) tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.14 @@ -9793,17 +10759,21 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.7): + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.8): dependencies: - react: 19.2.7 - react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.7) - react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.7) + react: 19.2.8 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.8) + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.8) tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.7) - use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.7) + use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.8) + use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.8) optionalDependencies: '@types/react': 19.2.14 + react-selecto@1.26.3: + dependencies: + selecto: 1.26.3 + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4): dependencies: get-nonce: 1.0.1 @@ -9812,10 +10782,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.7): + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.8): dependencies: get-nonce: 1.0.1 - react: 19.2.7 + react: 19.2.8 tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.14 @@ -9830,6 +10800,8 @@ snapshots: react@19.2.7: {} + react@19.2.8: {} + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -9868,6 +10840,13 @@ snapshots: optionalDependencies: '@react-email/render': 2.0.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + resend@6.12.3(@react-email/render@2.0.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8)): + dependencies: + postal-mime: 2.7.4 + svix: 1.92.2 + optionalDependencies: + '@react-email/render': 2.0.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -9930,12 +10909,28 @@ snapshots: dependencies: parseley: 0.12.1 + selecto@1.26.3: + dependencies: + '@daybrush/utils': 1.13.0 + '@egjs/children-differ': 1.0.1 + '@scena/dragscroll': 1.4.0 + '@scena/event-emitter': 1.0.5 + css-styled: 1.0.8 + css-to-mat: 1.1.1 + framework-utils: 1.1.0 + gesto: 1.19.4 + keycon: 1.4.0 + overlap-area: 1.1.0 + semver@6.3.1: {} semver@7.6.2: {} semver@7.7.3: {} + semver@7.8.5: + optional: true + send@0.19.2: dependencies: debug: 2.6.9 @@ -10115,6 +11110,40 @@ snapshots: '@img/sharp-win32-x64': 0.34.5 optional: true + sharp@0.35.3(@types/node@20.19.41): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.3 + '@img/sharp-darwin-x64': 0.35.3 + '@img/sharp-freebsd-wasm32': 0.35.3 + '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-linux-arm': 0.35.3 + '@img/sharp-linux-arm64': 0.35.3 + '@img/sharp-linux-ppc64': 0.35.3 + '@img/sharp-linux-riscv64': 0.35.3 + '@img/sharp-linux-s390x': 0.35.3 + '@img/sharp-linux-x64': 0.35.3 + '@img/sharp-linuxmusl-arm64': 0.35.3 + '@img/sharp-linuxmusl-x64': 0.35.3 + '@img/sharp-webcontainers-wasm32': 0.35.3 + '@img/sharp-win32-arm64': 0.35.3 + '@img/sharp-win32-ia32': 0.35.3 + '@img/sharp-win32-x64': 0.35.3 + '@types/node': 20.19.41 + optional: true + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -10216,6 +11245,11 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) + sonner@2.0.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + source-map-js@1.2.1: {} source-map@0.6.1: {} @@ -10279,6 +11313,13 @@ snapshots: stubborn-utils@1.0.2: {} + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.8): + dependencies: + client-only: 0.0.1 + react: 19.2.8 + optionalDependencies: + '@babel/core': 7.29.0 + styled-jsx@5.1.6(react@19.2.7): dependencies: client-only: 0.0.1 @@ -10473,9 +11514,9 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.7): + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.8): dependencies: - react: 19.2.7 + react: 19.2.8 tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.14 @@ -10488,10 +11529,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.7): + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.8): dependencies: detect-node-es: 1.1.0 - react: 19.2.7 + react: 19.2.8 tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.14 @@ -10504,6 +11545,10 @@ snapshots: dependencies: react: 19.2.7 + use-sync-external-store@1.6.0(react@19.2.8): + dependencies: + react: 19.2.8 + util-deprecate@1.0.2: {} utils-merge@1.0.1: {} @@ -10516,11 +11561,11 @@ snapshots: vary@1.1.2: {} - vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -10613,8 +11658,12 @@ snapshots: zod@4.4.3: {} - zustand@5.0.14(@types/react@19.2.14)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)): + zundo@2.3.0(zustand@5.0.14(@types/react@19.2.14)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8))): + dependencies: + zustand: 5.0.14(@types/react@19.2.14)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)) + + zustand@5.0.14(@types/react@19.2.14)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)): optionalDependencies: '@types/react': 19.2.14 - react: 19.2.7 - use-sync-external-store: 1.6.0(react@19.2.7) + react: 19.2.8 + use-sync-external-store: 1.6.0(react@19.2.8) From f47f9f4bd82336e501c4c47120997c0c43bac16c Mon Sep 17 00:00:00 2001 From: Leo Constantin Date: Thu, 6 Aug 2026 10:02:28 +0200 Subject: [PATCH 2/6] added achived page --- apps/dashboard/app/(app)/archived/page.tsx | 14 ++ apps/dashboard/app/(app)/page.tsx | 2 +- .../components/controls/CleanUploadState.tsx | 139 +++--------------- .../app/demo/video-editor/aspect-ratio.tsx | 25 ++-- .../app/demo/video-editor/studio-canvas.tsx | 29 ++-- apps/dashboard/features/app/home/header.tsx | 2 +- apps/dashboard/features/app/home/layout.tsx | 10 +- apps/dashboard/features/app/home/list.tsx | 4 +- .../features/app/home/sibebar/index.tsx | 29 ++-- .../features/app/home/sibebar/new-folder.tsx | 72 +++++++++ apps/dashboard/lib/store/index.ts | 2 +- packages/ui/src/components/dialog.tsx | 6 +- 12 files changed, 154 insertions(+), 180 deletions(-) create mode 100644 apps/dashboard/app/(app)/archived/page.tsx create mode 100644 apps/dashboard/features/app/home/sibebar/new-folder.tsx diff --git a/apps/dashboard/app/(app)/archived/page.tsx b/apps/dashboard/app/(app)/archived/page.tsx new file mode 100644 index 0000000..fd36b73 --- /dev/null +++ b/apps/dashboard/app/(app)/archived/page.tsx @@ -0,0 +1,14 @@ +import { HomeLayout } from "@/features/app/home/layout"; + +export default function ArchivedDemos() { + return ( + +
+

No Archived Demos

+

+ All archived demos will be listed here. +

+
+
+ ); +} diff --git a/apps/dashboard/app/(app)/page.tsx b/apps/dashboard/app/(app)/page.tsx index e9d43f2..dedb30e 100644 --- a/apps/dashboard/app/(app)/page.tsx +++ b/apps/dashboard/app/(app)/page.tsx @@ -5,7 +5,7 @@ import { HomeList } from "@/features/app/home/list"; export default function Home() { return ( -
+
diff --git a/apps/dashboard/components/controls/CleanUploadState.tsx b/apps/dashboard/components/controls/CleanUploadState.tsx index 18a752e..f93682c 100644 --- a/apps/dashboard/components/controls/CleanUploadState.tsx +++ b/apps/dashboard/components/controls/CleanUploadState.tsx @@ -1,18 +1,16 @@ "use client"; import { Button } from "@castfy/ui/components/button"; -import { Input } from "@castfy/ui/components/input"; -import { cn } from "@castfy/ui/lib/utils"; import { - Camera01Icon, - CommandIcon, - Globe02Icon, - Loading03Icon, -} from "hugeicons-react"; -import { Moon, Sun } from "lucide-react"; + InputGroup, + InputGroupAddon, + InputGroupInput, +} from "@castfy/ui/components/input-group"; +import { cn } from "@castfy/ui/lib/utils"; +import { Loading03Icon } from "hugeicons-react"; +import { ArrowDownIcon, ClapperboardIcon } from "lucide-react"; import React from "react"; import { useDropzone } from "react-dropzone"; -import { SegmentedControl } from "@/components/ui/segmented-control"; import { ALLOWED_IMAGE_TYPES, MAX_IMAGE_SIZE } from "@/lib/constants"; import { getBackgroundCSS } from "@/lib/constants/backgrounds"; import { useEditorStore, useImageStore } from "@/lib/store"; @@ -357,7 +355,7 @@ export function CleanUploadState() { className={cn( "relative z-10 flex cursor-pointer flex-col items-center justify-center", "h-[80%] w-[80%] rounded-2xl", - "bg-foreground/5 backdrop-blur-sm", + "bg-muted/20 backdrop-blur-sm", "border border-foreground/10", "transition-all duration-300 ease-out", "hover:border-foreground/15 hover:bg-foreground/8", @@ -365,79 +363,21 @@ export function CleanUploadState() { )} onClick={open} > - {/* Plus icon */} - - - - - {/* Placeholder text */} -

- {active +

+ {/* {active ? "Drop the image here..." - : "Drag & drop, click to browse, or paste"} + : "Drag & drop, click to browse, or paste"} */} + Generate video with our agent

- {!active && ( -
- - - V - - - to paste -
- )} - {/* Screenshot URL input */} {!active && (
e.stopPropagation()} > -
+
-
- - + setScreenshotUrl(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleCaptureScreenshot() } - placeholder="Enter website URL..." - style={{ color: "rgba(255,255,255,0.9)" }} + placeholder="Enter video URL..." type="url" value={screenshotUrl} /> -
- setColorScheme(value as ColorScheme)} - options={[ - { - id: "light", - icon: , - ariaLabel: "Light", - }, - { - id: "dark", - icon: , - ariaLabel: "Dark", - }, - ]} - size="sm" - value={colorScheme} - /> -
+ + + + +
diff --git a/apps/dashboard/features/app/demo/video-editor/aspect-ratio.tsx b/apps/dashboard/features/app/demo/video-editor/aspect-ratio.tsx index edaf03f..30f1b43 100644 --- a/apps/dashboard/features/app/demo/video-editor/aspect-ratio.tsx +++ b/apps/dashboard/features/app/demo/video-editor/aspect-ratio.tsx @@ -2,10 +2,10 @@ import { Button } from "@castfy/ui/components/button"; import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@castfy/ui/components/popover"; + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@castfy/ui/components/dropdown-menu"; import { AspectRatioIcon } from "hugeicons-react"; import { useState } from "react"; import { AspectRatioPicker } from "@/components/aspect-ratio/aspect-ratio-picker"; @@ -19,8 +19,8 @@ export function AspectRatio() { (ar) => ar.id === selectedAspectRatio ); return ( - - + + - - + + setAspectRatioOpen(false)} /> - - + + ); } diff --git a/apps/dashboard/features/app/demo/video-editor/studio-canvas.tsx b/apps/dashboard/features/app/demo/video-editor/studio-canvas.tsx index 28e220c..7acb40f 100644 --- a/apps/dashboard/features/app/demo/video-editor/studio-canvas.tsx +++ b/apps/dashboard/features/app/demo/video-editor/studio-canvas.tsx @@ -20,13 +20,10 @@ export function StudioCanvas() { const { slides, setActiveSlide, - activeSlideId, - removeSlide, previewIndex, isPreviewing, stopPreview, uploadedImageUrl, - showTimeline, selectedAspectRatio, } = useImageStore(); @@ -78,21 +75,17 @@ export function StudioCanvas() { : 16 / 9; return ( -
-
-
- - {/* */} - {/* cleam */} -
+
+
+
); diff --git a/apps/dashboard/features/app/home/header.tsx b/apps/dashboard/features/app/home/header.tsx index 20b074d..5eb1670 100644 --- a/apps/dashboard/features/app/home/header.tsx +++ b/apps/dashboard/features/app/home/header.tsx @@ -5,7 +5,7 @@ import { HomeFilters } from "./filters"; export function HomeHeader() { return ( -
+

All

diff --git a/apps/dashboard/features/app/home/layout.tsx b/apps/dashboard/features/app/home/layout.tsx index 78107af..3cdd435 100644 --- a/apps/dashboard/features/app/home/layout.tsx +++ b/apps/dashboard/features/app/home/layout.tsx @@ -1,16 +1,14 @@ import HomeSidebar from "./sibebar"; -export function HomeLayout({ - children, -}: { - children: React.ReactNode; -}) { +export function HomeLayout({ children }: { children: React.ReactNode }) { return (
-
{children}
+
+ {children} +
); diff --git a/apps/dashboard/features/app/home/list.tsx b/apps/dashboard/features/app/home/list.tsx index 8ddf833..6b48e2b 100644 --- a/apps/dashboard/features/app/home/list.tsx +++ b/apps/dashboard/features/app/home/list.tsx @@ -3,10 +3,10 @@ import { DemoCard } from "./demo/card"; export function HomeList() { return ( - <> +
{demos.map((demo) => ( ))} - +
); } diff --git a/apps/dashboard/features/app/home/sibebar/index.tsx b/apps/dashboard/features/app/home/sibebar/index.tsx index 8e0801f..ea8bc6e 100644 --- a/apps/dashboard/features/app/home/sibebar/index.tsx +++ b/apps/dashboard/features/app/home/sibebar/index.tsx @@ -1,12 +1,14 @@ "use client"; import { Button } from "@castfy/ui/components/button"; import { cn } from "@castfy/ui/lib/utils"; -import { Grid2X2Icon, MailIcon, PlusIcon, Trash2Icon } from "lucide-react"; +import { Grid2X2Icon, MailIcon, Trash2Icon } from "lucide-react"; +import Link from "next/link"; import { usePathname } from "next/navigation"; import { Suspense } from "react"; import SidebarSearch from "../../_shared/search"; import { AllDropdownActions, ArchiveDropdownActions } from "./actions"; import { HomeDropMenu } from "./menu"; +import { NewFolder } from "./new-folder"; export default function HomeSidebar({ className }: { className?: string }) { const pathname = usePathname(); @@ -33,40 +35,33 @@ export default function HomeSidebar({ className }: { className?: string }) { asChild className={cn( "group relative w-full justify-normal gap-3 text-muted-foreground", - pathname === "/demos" && "text-foreground" + pathname === "/" && "text-foreground" )} size="sm" - variant={pathname === "/demos" ? "secondary" : "ghost"} + variant={pathname === "/" ? "secondary" : "ghost"} > -
+ All -
+ - +
diff --git a/apps/dashboard/features/app/home/sibebar/new-folder.tsx b/apps/dashboard/features/app/home/sibebar/new-folder.tsx new file mode 100644 index 0000000..78fcc63 --- /dev/null +++ b/apps/dashboard/features/app/home/sibebar/new-folder.tsx @@ -0,0 +1,72 @@ +import { Button } from "@castfy/ui/components/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@castfy/ui/components/dialog"; +import { Field, FieldGroup } from "@castfy/ui/components/field"; +import { Input } from "@castfy/ui/components/input"; +import { Label } from "@castfy/ui/components/label"; +import { PlusIcon } from "lucide-react"; + +export function NewFolder() { + return ( + + + + + + + + New Folder + + + Create new folder + + +
e.preventDefault()} + > + + + + + + +

+ Create a new folder to help organize your projects. +

+
+ + + + +
+
+
+
+ ); +} diff --git a/apps/dashboard/lib/store/index.ts b/apps/dashboard/lib/store/index.ts index 82c5c20..a0b1f58 100644 --- a/apps/dashboard/lib/store/index.ts +++ b/apps/dashboard/lib/store/index.ts @@ -781,7 +781,7 @@ export const useImageStore = create()( selectedGradient: "vibrant_orange_pink", borderRadius: 10, backgroundBorderRadius: 10, - selectedAspectRatio: "4_3", + selectedAspectRatio: "16_9", customDimensions: null, backgroundConfig: { type: "image", diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx index 2de9541..66b992b 100644 --- a/packages/ui/src/components/dialog.tsx +++ b/packages/ui/src/components/dialog.tsx @@ -104,18 +104,18 @@ function DialogFooter({ return (
- {children} {showCloseButton && ( - + )} + {children}
); } From 9251b2c5af247b8a91dd14582a4364db554c2ebc Mon Sep 17 00:00:00 2001 From: Leo Constantin Date: Thu, 6 Aug 2026 14:05:15 +0200 Subject: [PATCH 3/6] removed canva for now --- .../dashboard/app/(app)/demos/[slug]/page.tsx | 2 - .../components/canvas/CanvasRulers.tsx | 354 -------- .../components/canvas/ClientCanvas.tsx | 758 ---------------- .../canvas/frames/BrowserToolbar.tsx | 234 ----- .../canvas/frames/Frame3DOverlay.tsx | 113 --- .../canvas/hooks/useImageLoading.ts | 256 ------ .../canvas/html/HTMLBackgroundLayer.tsx | 292 ------ .../canvas/html/HTMLBlurRegionLayer.tsx | 317 ------- .../canvas/html/HTMLCanvasRenderer.tsx | 48 - .../components/canvas/html/HTMLGridLayer.tsx | 31 - .../canvas/html/HTMLImageOverlayLayer.tsx | 373 -------- .../canvas/html/HTMLMainImageLayer.tsx | 850 ------------------ .../components/canvas/html/HTMLNoiseLayer.tsx | 37 - .../canvas/html/HTMLPatternLayer.tsx | 49 - .../canvas/html/HTMLTextOverlayLayer.tsx | 165 ---- .../canvas/html/SVGAnnotationLayer.tsx | 794 ---------------- .../canvas/html/SnapAlignmentGuides.tsx | 65 -- .../dashboard/components/canvas/html/index.ts | 12 - .../canvas/overlays/ArcFrameOverlay.tsx | 218 ----- .../canvas/overlays/Perspective3DOverlay.tsx | 273 ------ .../canvas/utils/canvas-dimensions.ts | 162 ---- .../components/canvas/utils/gradient-utils.ts | 90 -- .../components/canvas/utils/shadow-utils.ts | 107 --- .../components/controls/CleanUploadState.tsx | 280 +----- .../features/app/demo/sidebar/agent/index.tsx | 2 + .../features/app/demo/sidebar/agent/url.tsx | 21 + .../app/demo/sidebar/background/index.tsx | 13 +- .../features/app/demo/video-editor/footer.tsx | 10 +- .../features/app/demo/video-editor/index.tsx | 2 - .../app/demo/video-editor/studio-canvas.tsx | 105 +-- .../features/app/demo/video-editor/video.tsx | 2 +- apps/dashboard/lib/store/index.ts | 2 +- 32 files changed, 53 insertions(+), 5984 deletions(-) delete mode 100644 apps/dashboard/components/canvas/CanvasRulers.tsx delete mode 100644 apps/dashboard/components/canvas/ClientCanvas.tsx delete mode 100644 apps/dashboard/components/canvas/frames/BrowserToolbar.tsx delete mode 100644 apps/dashboard/components/canvas/frames/Frame3DOverlay.tsx delete mode 100644 apps/dashboard/components/canvas/hooks/useImageLoading.ts delete mode 100644 apps/dashboard/components/canvas/html/HTMLBackgroundLayer.tsx delete mode 100644 apps/dashboard/components/canvas/html/HTMLBlurRegionLayer.tsx delete mode 100644 apps/dashboard/components/canvas/html/HTMLCanvasRenderer.tsx delete mode 100644 apps/dashboard/components/canvas/html/HTMLGridLayer.tsx delete mode 100644 apps/dashboard/components/canvas/html/HTMLImageOverlayLayer.tsx delete mode 100644 apps/dashboard/components/canvas/html/HTMLMainImageLayer.tsx delete mode 100644 apps/dashboard/components/canvas/html/HTMLNoiseLayer.tsx delete mode 100644 apps/dashboard/components/canvas/html/HTMLPatternLayer.tsx delete mode 100644 apps/dashboard/components/canvas/html/HTMLTextOverlayLayer.tsx delete mode 100644 apps/dashboard/components/canvas/html/SVGAnnotationLayer.tsx delete mode 100644 apps/dashboard/components/canvas/html/SnapAlignmentGuides.tsx delete mode 100644 apps/dashboard/components/canvas/html/index.ts delete mode 100644 apps/dashboard/components/canvas/overlays/ArcFrameOverlay.tsx delete mode 100644 apps/dashboard/components/canvas/overlays/Perspective3DOverlay.tsx delete mode 100644 apps/dashboard/components/canvas/utils/canvas-dimensions.ts delete mode 100644 apps/dashboard/components/canvas/utils/gradient-utils.ts delete mode 100644 apps/dashboard/components/canvas/utils/shadow-utils.ts create mode 100644 apps/dashboard/features/app/demo/sidebar/agent/url.tsx diff --git a/apps/dashboard/app/(app)/demos/[slug]/page.tsx b/apps/dashboard/app/(app)/demos/[slug]/page.tsx index 3c7df94..1c4051a 100644 --- a/apps/dashboard/app/(app)/demos/[slug]/page.tsx +++ b/apps/dashboard/app/(app)/demos/[slug]/page.tsx @@ -4,8 +4,6 @@ import AppVideoEditor from "@/features/app/demo/video-editor"; export default function DemoPage() { return (
- {/**/} -